1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-12-29 16:24:01 +01:00

10 Commits

99 changed files with 2953 additions and 769 deletions

View File

@@ -3,10 +3,11 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "8.0.3", "version": "9.0.9",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ],
"rollForward": false
} }
} }
} }

View File

@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 1" />
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" /> <env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" /> <env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,14 +13,12 @@
<env name="MAX_INSTANCES" value="3" /> <env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" /> <env name="MAX_MEMORY" value="12G" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />

View File

@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 2" />
<env name="ALLOWED_RCON_PORTS" value="27002-27006" /> <env name="ALLOWED_RCON_PORTS" value="27002-27006" />
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" /> <env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,14 +13,12 @@
<env name="MAX_INSTANCES" value="5" /> <env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" /> <env name="MAX_MEMORY" value="10G" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />

View File

@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 3" />
<env name="ALLOWED_RCON_PORTS" value="27007" /> <env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" /> <env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,14 +13,12 @@
<env name="MAX_INSTANCES" value="1" /> <env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" /> <env name="MAX_MEMORY" value="2560M" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />

View File

@@ -7,17 +7,15 @@
<envs> <envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" /> <env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
<env name="WEB_KEY" value="T5Y722D2GZBXT2H27QS95P2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" /> <env name="WEB_KEY" value="G9WXPDGCGHJD9W9XBPMNYWN6YTK7NKRWHT29P2XKNDCBWKHWXP2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" />
<env name="WEB_SERVER_HOST" value="localhost" /> <env name="WEB_SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />

View File

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

View File

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

View File

@@ -0,0 +1,289 @@
using System.Collections.Immutable;
using NUnit.Framework;
using Phantom.Agent.Minecraft.Java;
using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Tests.Java;
[TestFixture]
public sealed class JavaPropertiesStreamTests {
public sealed class Reader {
private static async Task<ImmutableArray<KeyValuePair<string, string>>> Parse(string contents) {
using var stream = new MemoryStream(JavaPropertiesStream.Encoding.GetBytes(contents));
using var properties = new JavaPropertiesStream.Reader(stream);
return await properties.ReadProperties(CancellationToken.None).ToImmutableArrayAsync();
}
private static ImmutableArray<KeyValuePair<string, string>> KeyValue(string key, string value) {
return [new KeyValuePair<string, string>(key, value)];
}
[TestCase("")]
[TestCase("\n")]
public async Task EmptyLinesAreIgnored(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(ImmutableArray<KeyValuePair<string, string>>.Empty));
}
[TestCase("# Comment")]
[TestCase("! Comment")]
[TestCase("# Comment\n! Comment")]
public async Task CommentsAreIgnored(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(ImmutableArray<KeyValuePair<string, string>>.Empty));
}
[TestCase("key=value")]
[TestCase("key= value")]
[TestCase("key =value")]
[TestCase("key = value")]
[TestCase("key:value")]
[TestCase("key: value")]
[TestCase("key :value")]
[TestCase("key : value")]
[TestCase("key value")]
[TestCase("key\tvalue")]
[TestCase("key\fvalue")]
[TestCase("key \t\fvalue")]
public async Task SimpleKeyValue(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", "value")));
}
[TestCase("key")]
[TestCase(" key")]
[TestCase(" key ")]
[TestCase("key=")]
[TestCase("key:")]
public async Task KeyWithoutValue(string contents) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", "")));
}
[TestCase(@"\#key=value", "#key")]
[TestCase(@"\!key=value", "!key")]
public async Task KeyBeginsWithEscapedComment(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"\=key=value", "=key")]
[TestCase(@"\:key=value", ":key")]
[TestCase(@"\ key=value", " key")]
[TestCase("\\\tkey=value", "\tkey")]
[TestCase("\\\fkey=value", "\fkey")]
public async Task KeyBeginsWithEscapedDelimiter(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"start\=end=value", "start=end")]
[TestCase(@"start\:end:value", "start:end")]
[TestCase(@"start\ end value", "start end")]
[TestCase(@"start\ \:\=end = value", "start :=end")]
[TestCase("start\\ \\\t\\\fend = value", "start \t\fend")]
public async Task KeyContainsEscapedDelimiter(string contents, string expectedKey) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue(expectedKey, "value")));
}
[TestCase(@"key = \ value", " value")]
[TestCase("key = \\\tvalue", "\tvalue")]
[TestCase("key = \\\fvalue", "\fvalue")]
[TestCase("key=\\ \\\t\\\fvalue", " \t\fvalue")]
public async Task ValueBeginsWithEscapedWhitespace(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase(@"key = value\", "value")]
public async Task ValueEndsWithTrailingBackslash(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=\\\0", "\0")]
[TestCase(@"key=\\", "\\")]
[TestCase(@"key=\t", "\t")]
[TestCase(@"key=\n", "\n")]
[TestCase(@"key=\r", "\r")]
[TestCase(@"key=\f", "\f")]
[TestCase(@"key=\u3053\u3093\u306b\u3061\u306f", "こんにちは")]
[TestCase(@"key=\u3053\u3093\u306B\u3061\u306F", "こんにちは")]
[TestCase("key=\\\0\\\\\\t\\n\\r\\f\\u3053", "\0\\\t\n\r\fこ")]
public async Task ValueContainsEscapedSpecialCharacters(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=first\\\nsecond", "first\nsecond")]
[TestCase("key=first\\\n second", "first\nsecond")]
[TestCase("key=first\\\n#second", "first\n#second")]
[TestCase("key=first\\\n!second", "first\n!second")]
public async Task ValueContainsNewLine(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[TestCase("key=first\\\n \\ second", "first\n second")]
[TestCase("key=first\\\n \\\tsecond", "first\n\tsecond")]
[TestCase("key=first\\\n \\\fsecond", "first\n\fsecond")]
[TestCase("key=first\\\n \t\f\\ second", "first\n second")]
public async Task ValueContainsNewLineWithEscapedLeadingWhitespace(string contents, string expectedValue) {
Assert.That(await Parse(contents), Is.EquivalentTo(KeyValue("key", expectedValue)));
}
[Test]
public async Task ExampleFile() {
// From Wikipedia: https://en.wikipedia.org/wiki/.properties
const string ExampleFile = """
# You are reading a comment in ".properties" file.
! The exclamation mark ('!') can also be used for comments.
# Comments are ignored.
# Blank lines are also ignored.
# Lines with "properties" contain a key and a value separated by a delimiting character.
# There are 3 delimiting characters: equal ('='), colon (':') and whitespace (' ', '\t' and '\f').
website = https://en.wikipedia.org/
language : English
topic .properties files
# A word on a line will just create a key with no value.
empty
# Whitespace that appears between the key, the delimiter and the value is ignored.
# This means that the following are equivalent (other than for readability).
hello=hello
hello = hello
# To start the value with whitespace, escape it with a backslash ('\').
whitespaceStart = \ <-This space is not ignored.
# Keys with the same name will be overwritten by the key that is the furthest in a file.
# For example the final value for "duplicateKey" will be "second".
duplicateKey = first
duplicateKey = second
# To use the delimiter characters inside a key, you need to escape them with a ('\').
# However, there is no need to do this in the value.
delimiterCharacters\:\=\ = This is the value for the key "delimiterCharacters\:\=\ "
# Adding a backslash ('\') at the end of a line means that the value continues on the next line.
multiline = This line \
continues
# If you want your value to include a backslash ('\'), it should be escaped by another backslash ('\').
path = c:\\wiki\\templates
# This means that if the number of backslashes ('\') at the end of the line is even, the next line is not included in the value.
# In the following example, the value for "evenKey" is "This is on one line\".
evenKey = This is on one line\\
# This line is a normal comment and is not included in the value for "evenKey".
# If the number of backslash ('\') is odd, then the next line is included in the value.
# In the following example, the value for "oddKey" is "This is line one and\# This is line two".
oddKey = This is line one and\\\
# This is line two
# Whitespace characters at the beginning of a line is removed.
# Make sure to add the spaces you need before the backslash ('\') on the first line.
# If you add them at the beginning of the next line, they will be removed.
# In the following example, the value for "welcome" is "Welcome to Wikipedia!".
welcome = Welcome to \
Wikipedia!
# If you need to add newlines and carriage returns, they need to be escaped using ('\n') and ('\r') respectively.
# You can also optionally escape tabs with ('\t') for readability purposes.
valueWithEscapes = This is a newline\n and a carriage return\r and a tab\t.
# You can also use Unicode escape characters (maximum of four hexadecimal digits).
# In the following example, the value for "encodedHelloInJapanese" is "こんにちは".
encodedHelloInJapanese = \u3053\u3093\u306b\u3061\u306f
""";
ImmutableArray<KeyValuePair<string, string>> result = [
new ("website", "https://en.wikipedia.org/"),
new ("language", "English"),
new ("topic", ".properties files"),
new ("empty", ""),
new ("hello", "hello"),
new ("hello", "hello"),
new ("whitespaceStart", @" <-This space is not ignored."),
new ("duplicateKey", "first"),
new ("duplicateKey", "second"),
new ("delimiterCharacters:= ", @"This is the value for the key ""delimiterCharacters:= """),
new ("multiline", "This line \ncontinues"),
new ("path", @"c:\wiki\templates"),
new ("evenKey", @"This is on one line\"),
new ("oddKey", "This is line one and\\\n# This is line two"),
new ("welcome", "Welcome to \nWikipedia!"),
new ("valueWithEscapes", "This is a newline\n and a carriage return\r and a tab\t."),
new ("encodedHelloInJapanese", "こんにちは"),
];
Assert.That(await Parse(ExampleFile), Is.EquivalentTo(result));
}
}
public sealed class Writer {
private static async Task<string> Write(Func<JavaPropertiesStream.Writer, Task> write) {
using var stream = new MemoryStream();
await using (var writer = new JavaPropertiesStream.Writer(stream)) {
await write(writer);
}
return JavaPropertiesStream.Encoding.GetString(stream.ToArray());
}
[TestCase("one line comment", "# one line comment\n")]
[TestCase("こんにちは", "# \\u3053\\u3093\\u306B\\u3061\\u306F\n")]
[TestCase("first line\nsecond line\r\nthird line", "# first line\n# second line\n# third line\n")]
public async Task Comment(string comment, string contents) {
Assert.That(await Write(writer => writer.WriteComment(comment, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("key", "value", "key=value\n")]
[TestCase("key", "", "key=\n")]
[TestCase("", "value", "=value\n")]
public async Task SimpleKeyValue(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("#key", "value", "\\#key=value\n")]
[TestCase("!key", "value", "\\!key=value\n")]
public async Task KeyBeginsWithEscapedComment(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("=key", "value", "\\=key=value\n")]
[TestCase(":key", "value", "\\:key=value\n")]
[TestCase(" key", "value", "\\ key=value\n")]
[TestCase("\tkey", "value", "\\tkey=value\n")]
[TestCase("\fkey", "value", "\\fkey=value\n")]
public async Task KeyBeginsWithEscapedDelimiter(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("start=end", "value", "start\\=end=value\n")]
[TestCase("start:end", "value", "start\\:end=value\n")]
[TestCase("start end", "value", "start\\ end=value\n")]
[TestCase("start :=end", "value", "start\\ \\:\\=end=value\n")]
[TestCase("start \t\fend", "value", "start\\ \\t\\fend=value\n")]
public async Task KeyContainsEscapedDelimiter(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("\\", "value", "\\\\=value\n")]
[TestCase("\t", "value", "\\t=value\n")]
[TestCase("\n", "value", "\\n=value\n")]
[TestCase("\r", "value", "\\r=value\n")]
[TestCase("\f", "value", "\\f=value\n")]
[TestCase("こんにちは", "value", "\\u3053\\u3093\\u306B\\u3061\\u306F=value\n")]
[TestCase("\\\t\n\r\fこ", "value", "\\\\\\t\\n\\r\\f\\u3053=value\n")]
[TestCase("first-line\nsecond-line\r\nthird-line", "value", "first-line\\nsecond-line\\r\\nthird-line=value\n")]
public async Task KeyContainsEscapedSpecialCharacters(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[TestCase("key", "\\", "key=\\\\\n")]
[TestCase("key", "\t", "key=\\t\n")]
[TestCase("key", "\n", "key=\\n\n")]
[TestCase("key", "\r", "key=\\r\n")]
[TestCase("key", "\f", "key=\\f\n")]
[TestCase("key", "こんにちは", "key=\\u3053\\u3093\\u306B\\u3061\\u306F\n")]
[TestCase("key", "\\\t\n\r\fこ", "key=\\\\\\t\\n\\r\\f\\u3053\n")]
[TestCase("key", "first line\nsecond line\r\nthird line", "key=first line\\nsecond line\\r\\nthird line\n")]
public async Task ValueContainsEscapedSpecialCharacters(string key, string value, string contents) {
Assert.That(await Write(writer => writer.WriteProperty(key, value, CancellationToken.None)), Is.EqualTo(contents));
}
[Test]
public async Task ExampleFile() {
string contents = await Write(static async writer => {
await writer.WriteComment("Comment", CancellationToken.None);
await writer.WriteProperty("key", "value", CancellationToken.None);
await writer.WriteProperty("multiline", "first line\nsecond line", CancellationToken.None);
});
Assert.That(contents, Is.EqualTo("# Comment\nkey=value\nmultiline=first line\\nsecond line\n"));
}
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,92 +1,52 @@
using System.Text; namespace Phantom.Agent.Minecraft.Java;
using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Java;
sealed class JavaPropertiesFileEditor { sealed class JavaPropertiesFileEditor {
private static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private readonly Dictionary<string, string> overriddenProperties = new (); private readonly Dictionary<string, string> overriddenProperties = new ();
public void Set(string key, string value) { public void Set(string key, string value) {
overriddenProperties[key] = value; overriddenProperties[key] = value;
} }
public async Task EditOrCreate(string filePath) { public async Task EditOrCreate(string filePath, string comment, CancellationToken cancellationToken) {
if (File.Exists(filePath)) { if (File.Exists(filePath)) {
string tmpFilePath = filePath + ".tmp"; string tmpFilePath = filePath + ".tmp";
File.Copy(filePath, tmpFilePath, overwrite: true); await Edit(filePath, tmpFilePath, comment, cancellationToken);
await EditFromCopyOrCreate(filePath, tmpFilePath);
File.Move(tmpFilePath, filePath, overwrite: true); File.Move(tmpFilePath, filePath, overwrite: true);
} }
else { else {
await EditFromCopyOrCreate(sourceFilePath: null, filePath); await Create(filePath, comment, cancellationToken);
} }
} }
private async Task EditFromCopyOrCreate(string? sourceFilePath, string targetFilePath) { private async Task Create(string targetFilePath, string comment, CancellationToken cancellationToken) {
var properties = new JavaProperties(); await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
if (sourceFilePath != null) { await targetWriter.WriteComment(comment, cancellationToken);
// 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) { foreach ((string key, string value) in overriddenProperties) {
properties[key] = value; await targetWriter.WriteProperty(key, value, cancellationToken);
}
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) { private async Task Edit(string sourceFilePath, string targetFilePath, string comment, CancellationToken cancellationToken) {
await WritePropertyComponent(writer, key, escapeSpaces: true); using var sourceReader = new JavaPropertiesStream.Reader(sourceFilePath);
await writer.WriteAsync('='); await using var targetWriter = new JavaPropertiesStream.Writer(targetFilePath);
await WritePropertyComponent(writer, value, escapeSpaces: false);
await writer.WriteLineAsync(); await targetWriter.WriteComment(comment, cancellationToken);
}
var remainingOverriddenPropertyKeys = new HashSet<string>(overriddenProperties.Keys);
private static async Task WritePropertyComponent(TextWriter writer, string component, bool escapeSpaces) {
for (int index = 0; index < component.Length; index++) { await foreach ((string key, string value) in sourceReader.ReadProperties(cancellationToken)) {
var c = component[index]; if (remainingOverriddenPropertyKeys.Remove(key)) {
switch (c) { await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
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;
} }
else {
await targetWriter.WriteProperty(key, value, cancellationToken);
}
}
foreach (string key in remainingOverriddenPropertyKeys) {
await targetWriter.WriteProperty(key, overriddenProperties[key], cancellationToken);
} }
} }
} }

View File

@@ -0,0 +1,284 @@
using System.Buffers;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Java;
static class JavaPropertiesStream {
internal static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private static FileStreamOptions CreateFileStreamOptions(FileMode mode, FileAccess access) {
return new FileStreamOptions {
Mode = mode,
Access = access,
Share = FileShare.Read,
Options = FileOptions.SequentialScan,
};
}
internal sealed class Reader : IDisposable {
private static readonly SearchValues<char> LineStartWhitespace = SearchValues.Create(' ', '\t', '\f');
private static readonly SearchValues<char> KeyValueDelimiter = SearchValues.Create('=', ':', ' ', '\t', '\f');
private static readonly SearchValues<char> Backslash = SearchValues.Create('\\');
private readonly StreamReader reader;
public Reader(Stream stream) {
this.reader = new StreamReader(stream, Encoding, leaveOpen: false);
}
public Reader(string path) {
this.reader = new StreamReader(path, Encoding, detectEncodingFromByteOrderMarks: false, CreateFileStreamOptions(FileMode.Open, FileAccess.Read));
}
public async IAsyncEnumerable<KeyValuePair<string, string>> ReadProperties([EnumeratorCancellation] CancellationToken cancellationToken) {
await foreach (string line in ReadLogicalLines(cancellationToken)) {
yield return ParseLine(line.AsSpan());
}
}
private async IAsyncEnumerable<string> ReadLogicalLines([EnumeratorCancellation] CancellationToken cancellationToken) {
StringBuilder nextLogicalLine = new StringBuilder();
while (await reader.ReadLineAsync(cancellationToken) is {} line) {
var span = line.AsSpan();
int startIndex = span.IndexOfAnyExcept(LineStartWhitespace);
if (startIndex == -1) {
continue;
}
if (nextLogicalLine.Length == 0 && (span[0] == '#' || span[0] == '!')) {
continue;
}
span = span[startIndex..];
if (IsEndEscaped(span)) {
nextLogicalLine.Append(span[..^1]);
nextLogicalLine.Append('\n');
}
else {
nextLogicalLine.Append(span);
yield return nextLogicalLine.ToString();
nextLogicalLine.Clear();
}
}
if (nextLogicalLine.Length > 0) {
yield return nextLogicalLine.ToString(startIndex: 0, nextLogicalLine.Length - 1); // Remove trailing new line.
}
}
private static KeyValuePair<string, string> ParseLine(ReadOnlySpan<char> line) {
int delimiterIndex = -1;
foreach (int candidateIndex in line.IndicesOf(KeyValueDelimiter)) {
if (candidateIndex == 0 || !IsEndEscaped(line[..candidateIndex])) {
delimiterIndex = candidateIndex;
break;
}
}
if (delimiterIndex == -1) {
return new KeyValuePair<string, string>(line.ToString(), string.Empty);
}
string key = ReadPropertyComponent(line[..delimiterIndex]);
line = line[(delimiterIndex + 1)..];
int valueStartIndex = line.IndexOfAnyExcept(KeyValueDelimiter);
string value = valueStartIndex == -1 ? string.Empty : ReadPropertyComponent(line[valueStartIndex..]);
return new KeyValuePair<string, string>(key, value);
}
private static string ReadPropertyComponent(ReadOnlySpan<char> component) {
StringBuilder builder = new StringBuilder();
int nextStartIndex = 0;
foreach (int backslashIndex in component.IndicesOf(Backslash)) {
if (backslashIndex == component.Length - 1) {
break;
}
if (backslashIndex < nextStartIndex) {
continue;
}
builder.Append(component[nextStartIndex..backslashIndex]);
int escapedIndex = backslashIndex + 1;
int escapedLength = 1;
char c = component[escapedIndex];
switch (c) {
case 't':
builder.Append('\t');
break;
case 'n':
builder.Append('\n');
break;
case 'r':
builder.Append('\r');
break;
case 'f':
builder.Append('\f');
break;
case 'u':
escapedLength += 4;
int hexRangeStart = escapedIndex + 1;
int hexRangeEnd = hexRangeStart + 4;
if (hexRangeEnd - 1 < component.Length) {
var hexString = component[hexRangeStart..hexRangeEnd];
int hexValue = int.Parse(hexString, NumberStyles.HexNumber);
builder.Append((char) hexValue);
}
else {
throw new FormatException("Malformed \\uxxxx encoding.");
}
break;
default:
builder.Append(c);
break;
}
nextStartIndex = escapedIndex + escapedLength;
}
builder.Append(component[nextStartIndex..]);
return builder.ToString();
}
private static bool IsEndEscaped(ReadOnlySpan<char> span) {
if (span.EndsWith('\\')) {
int trailingBackslashCount = span.Length - span.TrimEnd('\\').Length;
return trailingBackslashCount % 2 == 1;
}
else {
return false;
}
}
public void Dispose() {
reader.Dispose();
}
}
internal sealed class Writer : IAsyncDisposable {
private const string CommentStart = "# ";
private readonly StreamWriter writer;
private readonly Memory<char> oneCharBuffer = new char[1];
public Writer(Stream stream) {
this.writer = new StreamWriter(stream, Encoding, leaveOpen: false);
}
public Writer(string path) {
this.writer = new StreamWriter(path, Encoding, CreateFileStreamOptions(FileMode.Create, FileAccess.Write));
}
public async Task WriteComment(string comment, CancellationToken cancellationToken) {
await Write(CommentStart, cancellationToken);
for (int index = 0; index < comment.Length; index++) {
char c = comment[index];
switch (c) {
case var _ when c > 31 && c < 127:
await Write(c, cancellationToken);
break;
case '\n':
case '\r':
await Write(c: '\n', cancellationToken);
await Write(CommentStart, cancellationToken);
if (index < comment.Length - 1 && comment[index + 1] == '\n') {
index++;
}
break;
default:
await Write("\\u", cancellationToken);
await Write(((int) c).ToString("X4"), cancellationToken);
break;
}
}
await Write(c: '\n', cancellationToken);
}
public async Task WriteProperty(string key, string value, CancellationToken cancellationToken) {
await WritePropertyComponent(key, escapeSpaces: true, cancellationToken);
await Write(c: '=', cancellationToken);
await WritePropertyComponent(value, escapeSpaces: false, cancellationToken);
await Write(c: '\n', cancellationToken);
}
private async Task WritePropertyComponent(string component, bool escapeSpaces, CancellationToken cancellationToken) {
for (int index = 0; index < component.Length; index++) {
char c = component[index];
switch (c) {
case '\\':
case '#':
case '!':
case '=':
case ':':
case ' ' when escapeSpaces || index == 0:
await Write(c: '\\', cancellationToken);
await Write(c, cancellationToken);
break;
case var _ when c > 31 && c < 127:
await Write(c, cancellationToken);
break;
case '\t':
await Write("\\t", cancellationToken);
break;
case '\n':
await Write("\\n", cancellationToken);
break;
case '\r':
await Write("\\r", cancellationToken);
break;
case '\f':
await Write("\\f", cancellationToken);
break;
default:
await Write("\\u", cancellationToken);
await Write(((int) c).ToString("X4"), cancellationToken);
break;
}
}
}
private Task Write(char c, CancellationToken cancellationToken) {
oneCharBuffer.Span[0] = c;
return writer.WriteAsync(oneCharBuffer, cancellationToken);
}
private Task Write(string value, CancellationToken cancellationToken) {
return writer.WriteAsync(value.AsMemory(), cancellationToken);
}
public async ValueTask DisposeAsync() {
await writer.DisposeAsync();
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Text; using System.Collections.ObjectModel;
using System.Text;
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;
@@ -11,7 +12,7 @@ public abstract class BaseLauncher : IServerLauncher {
private readonly InstanceProperties instanceProperties; private readonly InstanceProperties instanceProperties;
protected string MinecraftVersion => instanceProperties.ServerVersion; protected string MinecraftVersion => instanceProperties.ServerVersion;
protected string InstanceFolder => instanceProperties.InstanceFolder;
private protected BaseLauncher(InstanceProperties instanceProperties) { private protected BaseLauncher(InstanceProperties instanceProperties) {
this.instanceProperties = instanceProperties; this.instanceProperties = instanceProperties;
} }
@@ -43,7 +44,7 @@ public abstract class BaseLauncher : IServerLauncher {
try { try {
await AcceptEula(instanceProperties); await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties); await UpdateServerProperties(instanceProperties, cancellationToken);
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server."); logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer(); return new LaunchResult.CouldNotConfigureMinecraftServer();
@@ -51,17 +52,14 @@ public abstract class BaseLauncher : IServerLauncher {
var processConfigurator = new ProcessConfigurator { var processConfigurator = new ProcessConfigurator {
FileName = javaRuntimeExecutable.ExecutablePath, FileName = javaRuntimeExecutable.ExecutablePath,
WorkingDirectory = instanceProperties.InstanceFolder, WorkingDirectory = InstanceFolder,
RedirectInput = true, RedirectInput = true,
UseShellExecute = false, UseShellExecute = false,
}; };
var processArguments = processConfigurator.ArgumentList; var processArguments = processConfigurator.ArgumentList;
PrepareJvmArguments(serverJar).Build(processArguments); PrepareJvmArguments(serverJar).Build(processArguments);
processArguments.Add("-jar"); PrepareJavaProcessArguments(processArguments, serverJar.FilePath);
processArguments.Add(serverJar.FilePath);
processArguments.Add("nogui");
var process = processConfigurator.CreateProcess(); var process = processConfigurator.CreateProcess();
var instanceProcess = new InstanceProcess(instanceProperties, process); var instanceProcess = new InstanceProcess(instanceProperties, process);
@@ -98,7 +96,12 @@ public abstract class BaseLauncher : IServerLauncher {
} }
private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {} private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
protected virtual void PrepareJavaProcessArguments(Collection<string> processArguments, string serverJarFilePath) {
processArguments.Add("-jar");
processArguments.Add(serverJarFilePath);
processArguments.Add("nogui");
}
private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) { private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
return Task.FromResult(new ServerJarInfo(serverJarPath)); return Task.FromResult(new ServerJarInfo(serverJarPath));
} }
@@ -108,9 +111,9 @@ public abstract class BaseLauncher : IServerLauncher {
await File.WriteAllLinesAsync(eulaFilePath, ["# EULA", "eula=true"], Encoding.UTF8); await File.WriteAllLinesAsync(eulaFilePath, ["# EULA", "eula=true"], Encoding.UTF8);
} }
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) { private static async Task UpdateServerProperties(InstanceProperties instanceProperties, CancellationToken cancellationToken) {
var serverPropertiesEditor = new JavaPropertiesFileEditor(); var serverPropertiesEditor = new JavaPropertiesFileEditor();
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor); instanceProperties.ServerProperties.SetTo(serverPropertiesEditor);
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties")); await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"), comment: "server.properties", cancellationToken);
} }
} }

View File

@@ -0,0 +1,29 @@
using System.Collections.ObjectModel;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class ForgeLauncher : BaseLauncher {
public ForgeLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}
private protected override void CustomizeJvmArguments(JvmArgumentBuilder arguments) {
arguments.AddProperty("terminal.ansi", "true"); // TODO
}
protected override void PrepareJavaProcessArguments(Collection<string> processArguments, string serverJarFilePath) {
if (OperatingSystem.IsWindows()) {
processArguments.Add("@libraries/net/minecraftforge/forge/1.20.1-47.2.0/win_args.txt");
}
else {
processArguments.Add("@libraries/net/minecraftforge/forge/1.20.1-47.2.0/unix_args.txt");
}
processArguments.Add("nogui");
}
private protected override Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
return Task.FromResult(new ServerJarInfo(Path.Combine(InstanceFolder, "run.sh")));
}
}

View File

@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Kajabity.Tools.Java" /> <InternalsVisibleTo Include="Phantom.Agent.Minecraft.Tests" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -97,6 +97,7 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
IServerLauncher launcher = configuration.MinecraftServerKind switch { IServerLauncher launcher = configuration.MinecraftServerKind switch {
MinecraftServerKind.Vanilla => new VanillaLauncher(properties), MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
MinecraftServerKind.Fabric => new FabricLauncher(properties), MinecraftServerKind.Fabric => new FabricLauncher(properties),
MinecraftServerKind.Forge => new ForgeLauncher(properties),
_ => InvalidLauncher.Instance, _ => InvalidLauncher.Instance,
}; };

View File

@@ -29,7 +29,7 @@ static class AgentKey {
} }
try { try {
Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 64); Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 128);
string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8); string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8);
return LoadFromToken(lines[0]); return LoadFromToken(lines[0]);
} catch (IOException e) { } catch (IOException e) {

View File

@@ -1,44 +0,0 @@
using System.Text;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent;
static class GuidFile {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(GuidFile));
private const string GuidFileName = "agent.guid";
public static async Task<Guid?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, GuidFileName);
if (File.Exists(filePath)) {
try {
var guid = await LoadGuidFromFile(filePath);
Logger.Information("Loaded existing agent GUID file.");
return guid;
} catch (Exception e) {
Logger.Fatal("Error reading agent GUID file: {Message}", e.Message);
return null;
}
}
Logger.Information("Creating agent GUID file: {FilePath}", filePath);
try {
var guid = Guid.NewGuid();
await File.WriteAllTextAsync(filePath, guid.ToString(), Encoding.ASCII);
return guid;
} catch (Exception e) {
Logger.Fatal("Error creating agent GUID file: {Message}", e.Message);
return null;
}
}
private static async Task<Guid> LoadGuidFromFile(string filePath) {
Files.RequireMaximumFileSize(filePath, maximumBytes: 128);
string contents = await File.ReadAllTextAsync(filePath, Encoding.ASCII);
return Guid.Parse(contents.Trim());
}
}

View File

@@ -30,7 +30,7 @@ try {
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion); PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop(); var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath); var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) { if (agentKey == null) {
@@ -42,12 +42,7 @@ try {
return 1; return 1;
} }
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath); var agentInfo = new AgentInfo(ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
if (agentGuid == null) {
return 1;
}
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken); var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken);
var agentRegistrationHandler = new AgentRegistrationHandler(); var agentRegistrationHandler = new AgentRegistrationHandler();

View File

@@ -11,7 +11,6 @@ sealed record Variables(
string JavaSearchPath, string JavaSearchPath,
string? AgentKeyToken, string? AgentKeyToken,
string? AgentKeyFilePath, string? AgentKeyFilePath,
string AgentName,
ushort MaxInstances, ushort MaxInstances,
RamAllocationUnits MaxMemory, RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts, AllowedPorts AllowedServerPorts,
@@ -28,7 +27,6 @@ sealed record Variables(
javaSearchPath, javaSearchPath,
agentKeyToken, agentKeyToken,
agentKeyFilePath, agentKeyFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require,
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require, (ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require, EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require,
@@ -45,7 +43,7 @@ sealed record Variables(
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; throw StopProcedureException.Instance;
} }
} }

View File

@@ -1,4 +1,5 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent; namespace Phantom.Common.Data.Web.Agent;
@@ -7,9 +8,11 @@ namespace Phantom.Common.Data.Web.Agent;
public sealed partial record Agent( public sealed partial record Agent(
[property: MemoryPackOrder(0)] Guid AgentGuid, [property: MemoryPackOrder(0)] Guid AgentGuid,
[property: MemoryPackOrder(1)] AgentConfiguration Configuration, [property: MemoryPackOrder(1)] AgentConfiguration Configuration,
[property: MemoryPackOrder(2)] AgentStats? Stats, [property: MemoryPackOrder(2)] ImmutableArray<byte> ConnectionKey,
[property: MemoryPackOrder(3)] IAgentConnectionStatus ConnectionStatus [property: MemoryPackOrder(3)] AgentRuntimeInfo RuntimeInfo,
[property: MemoryPackOrder(4)] AgentStats? Stats,
[property: MemoryPackOrder(5)] IAgentConnectionStatus ConnectionStatus
) { ) {
[MemoryPackIgnore] [MemoryPackIgnore]
public RamAllocationUnits? AvailableMemory => Configuration.MaxMemory - Stats?.RunningInstanceMemory; public RamAllocationUnits? AvailableMemory => RuntimeInfo.MaxMemory - Stats?.RunningInstanceMemory;
} }

View File

@@ -1,19 +1,8 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent; namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentConfiguration( public sealed partial record AgentConfiguration(
[property: MemoryPackOrder(0)] string AgentName, [property: MemoryPackOrder(0)] string AgentName
[property: MemoryPackOrder(1)] ushort ProtocolVersion, );
[property: MemoryPackOrder(2)] string BuildVersion,
[property: MemoryPackOrder(3)] ushort MaxInstances,
[property: MemoryPackOrder(4)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(6)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentConfiguration From(AgentInfo agentInfo) {
return new AgentConfiguration(agentInfo.AgentName, agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}

View File

@@ -0,0 +1,17 @@
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentRuntimeInfo(
[property: MemoryPackOrder(0)] AgentVersionInfo? VersionInfo = null,
[property: MemoryPackOrder(1)] ushort? MaxInstances = null,
[property: MemoryPackOrder(2)] RamAllocationUnits? MaxMemory = null,
[property: MemoryPackOrder(3)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(4)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentRuntimeInfo From(AgentInfo agentInfo) {
return new AgentRuntimeInfo(new AgentVersionInfo(agentInfo.ProtocolVersion, agentInfo.BuildVersion), agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}

View File

@@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct AgentVersionInfo(
[property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string BuildVersion
);

View File

@@ -0,0 +1,17 @@
namespace Phantom.Common.Data.Web.Agent;
public enum CreateOrUpdateAgentResult : byte {
UnknownError,
Success,
AgentNameMustNotBeEmpty,
}
public static class CreateOrUpdateAgentResultExtensions {
public static string ToSentence(this CreateOrUpdateAgentResult reason) {
return reason switch {
CreateOrUpdateAgentResult.Success => "Success.",
CreateOrUpdateAgentResult.AgentNameMustNotBeEmpty => "Agent name must not be empty.",
_ => "Unknown error.",
};
}
}

View File

@@ -9,6 +9,8 @@ public enum AuditLogEventType {
UserPasswordChanged, UserPasswordChanged,
UserRolesChanged, UserRolesChanged,
UserDeleted, UserDeleted,
AgentCreated,
AgentEdited,
InstanceCreated, InstanceCreated,
InstanceEdited, InstanceEdited,
InstanceLaunched, InstanceLaunched,
@@ -26,6 +28,8 @@ public static class AuditLogEventTypeExtensions {
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User }, { AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
{ AuditLogEventType.AgentCreated, AuditLogSubjectType.Agent },
{ AuditLogEventType.AgentEdited, AuditLogSubjectType.Agent },
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance },

View File

@@ -2,5 +2,6 @@
public enum AuditLogSubjectType { public enum AuditLogSubjectType {
User, User,
Agent,
Instance, Instance,
} }

View File

@@ -4,12 +4,10 @@ namespace Phantom.Common.Data.Agent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentInfo( public sealed partial record AgentInfo(
[property: MemoryPackOrder(0)] Guid AgentGuid, [property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string AgentName, [property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] ushort ProtocolVersion, [property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] string BuildVersion, [property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] ushort MaxInstances, [property: MemoryPackOrder(4)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory, [property: MemoryPackOrder(5)] AllowedPorts AllowedRconPorts
[property: MemoryPackOrder(6)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(7)] AllowedPorts AllowedRconPorts
); );

View File

@@ -1,20 +1,21 @@
using Phantom.Utils.Rpc; using System.Collections.Immutable;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Common.Data; namespace Phantom.Common.Data;
public readonly record struct ConnectionKey(RpcCertificateThumbprint CertificateThumbprint, AuthToken AuthToken) { public readonly record struct ConnectionKey(RpcCertificateThumbprint CertificateThumbprint, AuthToken AuthToken) {
private const byte TokenLength = AuthToken.Length; private const byte TokenLength = AuthToken.Length;
public byte[] ToBytes() { public ImmutableArray<byte> ToBytes() {
Span<byte> result = stackalloc byte[TokenLength + CertificateThumbprint.Bytes.Length]; Span<byte> result = stackalloc byte[TokenLength + CertificateThumbprint.Bytes.Length];
AuthToken.Bytes.CopyTo(result[..TokenLength]); AuthToken.ToBytes(result[..TokenLength]);
CertificateThumbprint.Bytes.CopyTo(result[TokenLength..]); CertificateThumbprint.Bytes.CopyTo(result[TokenLength..]);
return result.ToArray(); return [..result];
} }
public static ConnectionKey FromBytes(ReadOnlySpan<byte> data) { public static ConnectionKey FromBytes(ReadOnlySpan<byte> data) {
var authToken = new AuthToken([..data[..TokenLength]]); var authToken = AuthToken.FromBytes(data[..TokenLength]);
var certificateThumbprint = RpcCertificateThumbprint.From(data[TokenLength..]); var certificateThumbprint = RpcCertificateThumbprint.From(data[TokenLength..]);
return new ConnectionKey(certificateThumbprint, authToken); return new ConnectionKey(certificateThumbprint, authToken);
} }

View File

@@ -3,4 +3,5 @@
public enum MinecraftServerKind : ushort { public enum MinecraftServerKind : ushort {
Vanilla = 1, Vanilla = 1,
Fabric = 2, Fabric = 2,
Forge = 3,
} }

View File

@@ -18,10 +18,10 @@ public static class AgentMessageRegistries {
ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(); ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>();
ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(); ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>();
ToController.Add<ReportInstanceStatusMessage>();
ToController.Add<InstanceOutputMessage>();
ToController.Add<ReportAgentStatusMessage>(); ToController.Add<ReportAgentStatusMessage>();
ToController.Add<ReportInstanceEventMessage>(); ToController.Add<ReportInstanceStatusMessage>();
ToController.Add<ReportInstancePlayerCountsMessage>(); ToController.Add<ReportInstancePlayerCountsMessage>();
ToController.Add<ReportInstanceEventMessage>();
ToController.Add<InstanceOutputMessage>();
} }
} }

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreateOrUpdateAgentMessage(
[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid AgentGuid,
[property: MemoryPackOrder(2)] AgentConfiguration Configuration
) : IMessageToController, ICanReply<Result<CreateOrUpdateAgentResult, UserActionFailure>>;

View File

@@ -3,6 +3,7 @@ using Phantom.Common.Data;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -25,17 +26,18 @@ public static class WebMessageRegistries {
ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(); ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>();
ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(); ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>();
ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(); ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>();
ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>();
ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(); ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>();
ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(); ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>();
ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(); ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>();
ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(); ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>();
ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>();
ToController.Add<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>();
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>();
ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(); ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>();
ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(); ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>();
ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(); ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>();
ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(); ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>();
ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(); ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>();
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>();
ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(); ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>();
ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(); ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>();

View File

@@ -0,0 +1,357 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Controller.Database;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251225053921_AgentAuthSecret")]
partial class AgentAuthSecret
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxInstances")
.HasColumnType("integer");
b.Property<int>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<int>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class AgentAuthSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "AuthSecret",
schema: "agents",
table: "Agents",
type: "bytea",
maxLength: 12,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AuthSecret",
schema: "agents",
table: "Agents");
}
}
}

View File

@@ -0,0 +1,356 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Controller.Database;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251228053557_AgentFieldNullability")]
partial class AgentFieldNullability
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.HasColumnType("text");
b.Property<int?>("MaxInstances")
.HasColumnType("integer");
b.Property<int?>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<int>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class AgentFieldNullability : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "ProtocolVersion",
schema: "agents",
table: "Agents",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "MaxMemory",
schema: "agents",
table: "Agents",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<int>(
name: "MaxInstances",
schema: "agents",
table: "Agents",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AlterColumn<string>(
name: "BuildVersion",
schema: "agents",
table: "Agents",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "ProtocolVersion",
schema: "agents",
table: "Agents",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "MaxMemory",
schema: "agents",
table: "Agents",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
name: "MaxInstances",
schema: "agents",
table: "Agents",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "BuildVersion",
schema: "agents",
table: "Agents",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}

View File

@@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -29,21 +29,24 @@ namespace Phantom.Controller.Database.Postgres.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion") b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("MaxInstances") b.Property<int?>("MaxInstances")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<ushort>("MaxMemory") b.Property<int?>("MaxMemory")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<int>("ProtocolVersion") b.Property<int?>("ProtocolVersion")
.HasColumnType("integer"); .HasColumnType("integer");
b.HasKey("AgentGuid"); b.HasKey("AgentGuid");
@@ -142,7 +145,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
b.Property<bool>("LaunchAutomatically") b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<ushort>("MemoryAllocation") b.Property<int>("MemoryAllocation")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("MinecraftServerKind") b.Property<string>("MinecraftServerKind")

View File

@@ -8,6 +8,7 @@ using Phantom.Common.Data.Web.EventLog;
using Phantom.Controller.Database.Converters; using Phantom.Controller.Database.Converters;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Factories; using Phantom.Controller.Database.Factories;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database; namespace Phantom.Controller.Database;
@@ -79,6 +80,8 @@ public class ApplicationDbContext : DbContext {
builder.Properties<EventLogEventType>().HaveConversion<EnumToStringConverter<EventLogEventType>>(); builder.Properties<EventLogEventType>().HaveConversion<EnumToStringConverter<EventLogEventType>>();
builder.Properties<EventLogSubjectType>().HaveConversion<EnumToStringConverter<EventLogSubjectType>>(); builder.Properties<EventLogSubjectType>().HaveConversion<EnumToStringConverter<EventLogSubjectType>>();
builder.Properties<MinecraftServerKind>().HaveConversion<EnumToStringConverter<MinecraftServerKind>>(); builder.Properties<MinecraftServerKind>().HaveConversion<EnumToStringConverter<MinecraftServerKind>>();
builder.Properties<AuthSecret>().HaveConversion<AuthSecretConverter>();
builder.Properties<RamAllocationUnits>().HaveConversion<RamAllocationUnitsConverter>(); builder.Properties<RamAllocationUnits>().HaveConversion<RamAllocationUnitsConverter>();
} }
} }

View File

@@ -0,0 +1,12 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database.Converters;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
sealed class AuthSecretConverter() : ValueConverter<AuthSecret, byte[]>(
static units => units.Bytes.ToArray(),
static value => new AuthSecret(ImmutableArray.Create(value))
);

View File

@@ -5,9 +5,7 @@ using Phantom.Common.Data;
namespace Phantom.Controller.Database.Converters; namespace Phantom.Controller.Database.Converters;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
sealed class RamAllocationUnitsConverter : ValueConverter<RamAllocationUnits, ushort> { sealed class RamAllocationUnitsConverter() : ValueConverter<RamAllocationUnits, ushort>(
public RamAllocationUnitsConverter() : base( static units => units.RawValue,
static units => units.RawValue, static value => new RamAllocationUnits(value)
static value => new RamAllocationUnits(value) );
) {}
}

View File

@@ -2,6 +2,8 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database.Entities; namespace Phantom.Controller.Database.Entities;
@@ -12,10 +14,17 @@ public sealed class AgentEntity {
public Guid AgentGuid { get; init; } public Guid AgentGuid { get; init; }
public string Name { get; set; } public string Name { get; set; }
public ushort ProtocolVersion { get; set; } public ushort? ProtocolVersion { get; set; }
public string BuildVersion { get; set; } public string? BuildVersion { get; set; }
public ushort MaxInstances { get; set; } public ushort? MaxInstances { get; set; }
public RamAllocationUnits MaxMemory { get; set; } public RamAllocationUnits? MaxMemory { get; set; }
[MaxLength(AuthSecret.Length)]
public AuthSecret? AuthSecret { get; set; }
public AgentConfiguration Configuration => new (Name);
public AgentVersionInfo? VersionInfo => ProtocolVersion is {} protocolVersion && BuildVersion is {} buildVersion ? new AgentVersionInfo(protocolVersion, buildVersion) : null;
public AgentRuntimeInfo RuntimeInfo => new (VersionInfo, MaxInstances, MaxMemory);
internal AgentEntity(Guid agentGuid) { internal AgentEntity(Guid agentGuid) {
AgentGuid = agentGuid; AgentGuid = agentGuid;

View File

@@ -14,15 +14,21 @@ public abstract class AbstractUpsertHelper<T> where T : class {
private protected abstract T Construct(Guid guid); private protected abstract T Construct(Guid guid);
public T Fetch(Guid guid) { public T Fetch(Guid guid) {
return Fetch(guid, out _);
}
public T Fetch(Guid guid, out bool wasCreated) {
DbSet<T> set = Set; DbSet<T> set = Set;
T? entity = set.Find(guid); T? entity = set.Find(guid);
if (entity == null) { if (entity == null) {
entity = Construct(guid); entity = Construct(guid);
set.Add(entity); set.Add(entity);
wasCreated = true;
} }
else { else {
set.Update(entity); set.Update(entity);
wasCreated = false;
} }
return entity; return entity;

View File

@@ -61,6 +61,14 @@ sealed partial class AuditLogRepository {
}); });
} }
public void AgentCreated(Guid agentGuid) {
AddItem(AuditLogEventType.AgentCreated, agentGuid.ToString());
}
public void AgentEdited(Guid agentGuid) {
AddItem(AuditLogEventType.AgentEdited, agentGuid.ToString());
}
public void InstanceCreated(Guid instanceGuid) { public void InstanceCreated(Guid instanceGuid) {
AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
} }

View File

@@ -150,12 +150,12 @@ sealed class MinecraftVersionApi : IDisposable {
private static JsonElement GetJsonPropertyOrThrow(JsonElement parentElement, string propertyKey, JsonValueKind expectedKind, string location) { private static JsonElement GetJsonPropertyOrThrow(JsonElement parentElement, string propertyKey, JsonValueKind expectedKind, string location) {
if (!parentElement.TryGetProperty(propertyKey, out var valueElement)) { if (!parentElement.TryGetProperty(propertyKey, out var valueElement)) {
Logger.Error("Missing \"{Property}\" key in " + location + ".", propertyKey); Logger.Error("Missing \"{Property}\" key in {Location}.", propertyKey, location);
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
if (valueElement.ValueKind != expectedKind) { if (valueElement.ValueKind != expectedKind) {
Logger.Error("The \"{Property}\" key in " + location + " does not contain a JSON {ExpectedType}. Actual type: {ActualType}", propertyKey, expectedKind, valueElement.ValueKind); Logger.Error("The \"{Property}\" key in {Location} does not contain a JSON {ExpectedType}. Actual type: {ActualType}", propertyKey, location, expectedKind, valueElement.ValueKind);
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }

View File

@@ -21,34 +21,53 @@ using Phantom.Utils.Actor.Mailbox;
using Phantom.Utils.Actor.Tasks; using Phantom.Utils.Actor.Tasks;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server; using Phantom.Utils.Rpc.Runtime.Server;
using Serilog; using Serilog;
namespace Phantom.Controller.Services.Agents; namespace Phantom.Controller.Services.Agents;
sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private static readonly ILogger Logger = PhantomLogger.Create<AgentActor>(); private static readonly ILogger Logger = PhantomLogger.Create<AgentActor>();
private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12); private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12);
public readonly record struct Init(Guid AgentGuid, AgentConfiguration AgentConfiguration, ControllerState ControllerState, MinecraftVersions MinecraftVersions, IDbContextProvider DbProvider, CancellationToken CancellationToken); public readonly record struct Init(
Guid? LoggedInUserGuid,
Guid AgentGuid,
AgentConfiguration AgentConfiguration,
AuthSecret AuthSecret,
AgentRuntimeInfo AgentRuntimeInfo,
AgentConnectionKeys AgentConnectionKeys,
ControllerState ControllerState,
MinecraftVersions MinecraftVersions,
IDbContextProvider DbProvider,
CancellationToken CancellationToken
);
public static Props<ICommand> Factory(Init init) { public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name }); return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name });
} }
public ITimerScheduler Timers { get; set; } = null!;
private readonly AgentConnectionKeys agentConnectionKeys;
private readonly ControllerState controllerState; private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions; private readonly MinecraftVersions minecraftVersions;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly Guid agentGuid; private readonly Guid agentGuid;
private readonly AuthInfo authInfo;
private AgentConfiguration configuration; private AgentConfiguration configuration;
private AgentRuntimeInfo runtimeInfo;
private AgentStats? stats; private AgentStats? stats;
private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty; private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty;
private string AgentName => configuration.AgentName;
private readonly AgentConnection connection; private readonly AgentConnection connection;
private DateTimeOffset? lastPingTime; private DateTimeOffset? lastPingTime;
@@ -74,23 +93,33 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
private readonly Dictionary<Guid, Instance> instanceDataByGuid = new (); private readonly Dictionary<Guid, Instance> instanceDataByGuid = new ();
private AgentActor(Init init) { private AgentActor(Init init) {
this.agentConnectionKeys = init.AgentConnectionKeys;
this.controllerState = init.ControllerState; this.controllerState = init.ControllerState;
this.minecraftVersions = init.MinecraftVersions; this.minecraftVersions = init.MinecraftVersions;
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.authInfo = new AuthInfo(this, init.AuthSecret);
this.configuration = init.AgentConfiguration; this.configuration = init.AgentConfiguration;
this.runtimeInfo = init.AgentRuntimeInfo;
this.connection = new AgentConnection(agentGuid, configuration.AgentName); this.connection = new AgentConnection(agentGuid, configuration.AgentName);
this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage"); this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
if (init.LoggedInUserGuid is {} loggedInUserGuid) {
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(loggedInUserGuid, configuration));
}
NotifyAgentUpdated(); NotifyAgentUpdated();
ReceiveAsync<InitializeCommand>(Initialize); ReceiveAsync<InitializeCommand>(Initialize);
Receive<ConfigureAgentCommand>(ConfigureAgent);
ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register); ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register);
Receive<SetConnectionCommand>(SetConnection); Receive<SetConnectionCommand>(SetConnection);
Receive<UnregisterCommand>(Unregister); Receive<UnregisterCommand>(Unregister);
ReceiveAndReply<GetAuthSecretCommand, AuthSecret>(GetAuthSecret);
Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus); Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus);
Receive<NotifyIsAliveCommand>(NotifyIsAlive); Receive<NotifyIsAliveCommand>(NotifyIsAlive);
Receive<UpdateStatsCommand>(UpdateStats); Receive<UpdateStatsCommand>(UpdateStats);
@@ -104,13 +133,13 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
} }
private void NotifyAgentUpdated() { private void NotifyAgentUpdated() {
controllerState.UpdateAgent(new Agent(agentGuid, configuration, stats, ConnectionStatus)); controllerState.UpdateAgent(new Agent(agentGuid, configuration, authInfo.ConnectionKey, runtimeInfo, stats, ConnectionStatus));
} }
protected override void PreStart() { protected override void PreStart() {
Self.Tell(new InitializeCommand()); Self.Tell(new InitializeCommand());
Context.System.Scheduler.ScheduleTellRepeatedly(DisconnectionRecheckInterval, DisconnectionRecheckInterval, Self, new RefreshConnectionStatusCommand(), Self); Timers.StartPeriodicTimer("RefreshConnectionStatus", new RefreshConnectionStatusCommand(), DisconnectionRecheckInterval, Self);
} }
private ActorRef<InstanceActor.ICommand> CreateNewInstance(Instance instance) { private ActorRef<InstanceActor.ICommand> CreateNewInstance(Instance instance) {
@@ -172,12 +201,16 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
private sealed record InitializeCommand : ICommand; private sealed record InitializeCommand : ICommand;
public sealed record RegisterCommand(AgentConfiguration Configuration, ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>; public sealed record ConfigureAgentCommand(Guid LoggedInUserGuid, AgentConfiguration Configuration) : ICommand;
public sealed record RegisterCommand(AgentRuntimeInfo RuntimeInfo, ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>;
public sealed record SetConnectionCommand(RpcServerToClientConnection<IMessageToController, IMessageToAgent> Connection) : ICommand; public sealed record SetConnectionCommand(RpcServerToClientConnection<IMessageToController, IMessageToAgent> Connection) : ICommand;
public sealed record UnregisterCommand : ICommand; public sealed record UnregisterCommand : ICommand;
public sealed record GetAuthSecretCommand : ICommand, ICanReply<AuthSecret>;
private sealed record RefreshConnectionStatusCommand : ICommand; private sealed record RefreshConnectionStatusCommand : ICommand;
public sealed record NotifyIsAliveCommand : ICommand; public sealed record NotifyIsAliveCommand : ICommand;
@@ -225,19 +258,24 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
} }
} }
private void ConfigureAgent(ConfigureAgentCommand message) {
configuration = message.Configuration;
NotifyAgentUpdated();
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(message.LoggedInUserGuid, configuration));
}
private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) { private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) {
var configurationMessages = await PrepareInitialConfigurationMessages(); var configurationMessages = await PrepareInitialConfigurationMessages();
configuration = command.Configuration; runtimeInfo = command.RuntimeInfo;
connection.SetAgentName(configuration.AgentName);
lastPingTime = DateTimeOffset.Now; lastPingTime = DateTimeOffset.Now;
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Information("Registered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(configuration)); databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentRuntimeInfoCommand(runtimeInfo));
javaRuntimes = command.JavaRuntimes; javaRuntimes = command.JavaRuntimes;
controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
@@ -259,7 +297,11 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline)); TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline));
Logger.Information("Unregistered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Information("Unregistered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
}
private AuthSecret GetAuthSecret(GetAuthSecretCommand command) {
return authInfo.Secret;
} }
private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) { private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) {
@@ -267,7 +309,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
isOnline = false; isOnline = false;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Warning("Lost connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -278,7 +320,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Restored connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Warning("Restored connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -327,19 +369,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
var isCreating = command.IsCreatingInstance; var isCreating = command.IsCreatingInstance;
if (result.Is(ConfigureInstanceResult.Success)) { if (result.Is(ConfigureInstanceResult.Success)) {
string action = isCreating ? "Added" : "Edited"; string action = isCreating ? "Created" : "Edited";
string relation = isCreating ? "to agent" : "in agent"; Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\".", instanceName, instanceGuid, AgentName);
Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName);
return CreateOrUpdateInstanceResult.Success; return CreateOrUpdateInstanceResult.Success;
} }
else { else {
string action = isCreating ? "adding" : "editing"; string action = isCreating ? "creating" : "editing";
string relation = isCreating ? "to agent" : "in agent";
string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence); string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, AgentName, reason);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason);
return CreateOrUpdateInstanceResult.UnknownError; return CreateOrUpdateInstanceResult.UnknownError;
} }
@@ -368,4 +406,26 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
private void ReceiveInstanceData(ReceiveInstanceDataCommand command) { private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {
UpdateInstanceData(command.Instance); UpdateInstanceData(command.Instance);
} }
private sealed class AuthInfo {
private readonly AgentActor actor;
public AuthSecret Secret { get; private set; }
public ImmutableArray<byte> ConnectionKey { get; private set; }
public AuthInfo(AgentActor actor, AuthSecret authSecret) {
this.actor = actor;
this.Secret = authSecret;
this.ConnectionKey = CreateConnectionKey(authSecret);
}
public void UpdateSecret(AuthSecret newSecret) {
this.Secret = newSecret;
this.ConnectionKey = CreateConnectionKey(newSecret);
}
private ImmutableArray<byte> CreateConnectionKey(AuthSecret authSecret) {
return actor.agentConnectionKeys.Get(new AuthToken(actor.agentGuid, authSecret)).ToBytes();
}
}
} }

View File

@@ -0,0 +1,11 @@
using Phantom.Common.Data;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Controller.Services.Agents;
sealed class AgentConnectionKeys(RpcCertificateThumbprint certificateThumbprint) {
public ConnectionKey Get(AuthToken authToken) {
return new ConnectionKey(certificateThumbprint, authToken);
}
}

View File

@@ -1,12 +1,16 @@
using Phantom.Common.Data.Web.Agent; using Akka.Actor;
using Phantom.Common.Data.Web.Agent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Repositories;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Serilog; using Serilog;
namespace Phantom.Controller.Services.Agents; namespace Phantom.Controller.Services.Agents;
sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.ICommand> { sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.ICommand>, IWithTimers {
private static readonly ILogger Logger = PhantomLogger.Create<AgentDatabaseStorageActor>(); private static readonly ILogger Logger = PhantomLogger.Create<AgentDatabaseStorageActor>();
public readonly record struct Init(Guid AgentGuid, IDbContextProvider DbProvider, CancellationToken CancellationToken); public readonly record struct Init(Guid AgentGuid, IDbContextProvider DbProvider, CancellationToken CancellationToken);
@@ -15,66 +19,111 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
return Props<ICommand>.Create(() => new AgentDatabaseStorageActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume }); return Props<ICommand>.Create(() => new AgentDatabaseStorageActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
} }
public ITimerScheduler Timers { get; set; } = null!;
private readonly Guid agentGuid; private readonly Guid agentGuid;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private AgentConfiguration? configurationToStore; private StoreAgentRuntimeInfoCommand? storeRuntimeInfoCommand;
private bool hasScheduledFlush;
private AgentDatabaseStorageActor(Init init) { private AgentDatabaseStorageActor(Init init) {
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
Receive<StoreAgentConfigurationCommand>(StoreAgentConfiguration); Receive<StoreAgentRuntimeInfoCommand>(StoreAgentRuntimeInfo);
ReceiveAsync<FlushChangesCommand>(FlushChanges); ReceiveAsync<StoreAgentConfigurationCommand>(StoreAgentConfiguration);
ReceiveAsync<FlushAgentRuntimeInfoCommand>(FlushAgentRuntimeInfo);
}
private ValueTask<AgentEntity?> FindAgentEntity(ILazyDbContext db) {
return db.Ctx.Agents.FindAsync([agentGuid], cancellationToken);
} }
public interface ICommand; public interface ICommand;
public sealed record StoreAgentConfigurationCommand(AgentConfiguration Configuration) : ICommand; public sealed record StoreAgentConfigurationCommand(Guid AuditLogUserGuid, AgentConfiguration Configuration) : ICommand;
private sealed record FlushChangesCommand : ICommand; public sealed record StoreAgentRuntimeInfoCommand(AgentRuntimeInfo RuntimeInfo) : ICommand;
private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) { private sealed record FlushAgentRuntimeInfoCommand : ICommand;
configurationToStore = command.Configuration;
private async Task StoreAgentConfiguration(StoreAgentConfigurationCommand command) {
await FlushAgentRuntimeInfo();
bool wasCreated;
await using (var db = dbProvider.Lazy()) {
var entity = db.Ctx.AgentUpsert.Fetch(agentGuid, out wasCreated);
if (wasCreated) {
entity.AuthSecret = AuthSecret.Generate();
}
entity.Name = command.Configuration.AgentName;
var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
if (wasCreated) {
auditLogWriter.AgentCreated(agentGuid);
}
else {
auditLogWriter.AgentEdited(agentGuid);
}
await db.Ctx.SaveChangesAsync(cancellationToken);
}
string action = wasCreated ? "Created" : "Edited";
Logger.Information(action + " agent \"{AgentName}\" (GUID {AgentGuid}) in database.", command.Configuration.AgentName, agentGuid);
}
private void StoreAgentRuntimeInfo(StoreAgentRuntimeInfoCommand command) {
storeRuntimeInfoCommand = command;
ScheduleFlush(TimeSpan.FromSeconds(2)); ScheduleFlush(TimeSpan.FromSeconds(2));
} }
private async Task FlushChanges(FlushChangesCommand command) { private void ScheduleFlush(TimeSpan delay) {
hasScheduledFlush = false; if (storeRuntimeInfoCommand != null) {
Timers.StartSingleTimer("FlushChanges", new FlushAgentRuntimeInfoCommand(), delay, Self);
if (configurationToStore == null) {
return;
} }
try {
await using var ctx = dbProvider.Eager();
var entity = ctx.AgentUpsert.Fetch(agentGuid);
entity.Name = configurationToStore.AgentName;
entity.ProtocolVersion = configurationToStore.ProtocolVersion;
entity.BuildVersion = configurationToStore.BuildVersion;
entity.MaxInstances = configurationToStore.MaxInstances;
entity.MaxMemory = configurationToStore.MaxMemory;
await ctx.SaveChangesAsync(cancellationToken);
} catch (Exception e) {
ScheduleFlush(TimeSpan.FromSeconds(10));
Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
return;
}
Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
configurationToStore = null;
} }
private void ScheduleFlush(TimeSpan delay) { private Task FlushAgentRuntimeInfo(FlushAgentRuntimeInfoCommand command) {
if (!hasScheduledFlush) { return FlushAgentRuntimeInfo();
hasScheduledFlush = true; }
Context.System.Scheduler.ScheduleTellOnce(delay, Self, new FlushChangesCommand(), Self);
private async Task FlushAgentRuntimeInfo() {
if (storeRuntimeInfoCommand == null) {
return;
} }
string agentName;
await using (var db = dbProvider.Lazy()) {
var entity = await FindAgentEntity(db);
if (entity == null) {
return;
}
agentName = entity.Name;
try {
entity.ProtocolVersion = storeRuntimeInfoCommand.RuntimeInfo.VersionInfo?.ProtocolVersion;
entity.BuildVersion = storeRuntimeInfoCommand.RuntimeInfo.VersionInfo?.BuildVersion;
entity.MaxInstances = storeRuntimeInfoCommand.RuntimeInfo.MaxInstances;
entity.MaxMemory = storeRuntimeInfoCommand.RuntimeInfo.MaxMemory;
await db.Ctx.SaveChangesAsync(cancellationToken);
} catch (Exception e) {
ScheduleFlush(TimeSpan.FromSeconds(10));
Logger.Error(e, "Could not update agent \"{AgentName}\" (GUID {AgentGuid}) in database.", entity.Name, agentGuid);
return;
}
}
Logger.Information("Updated agent \"{AgentName}\" (GUID {AgentGuid}) in database.", agentName, agentGuid);
storeRuntimeInfoCommand = null;
} }
} }

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable; using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
@@ -8,61 +9,76 @@ using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Agent.Handshake; using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Minecraft; using Phantom.Controller.Minecraft;
using Phantom.Controller.Services.Users.Sessions; using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Serilog; using Serilog;
namespace Phantom.Controller.Services.Agents; namespace Phantom.Controller.Services.Agents;
sealed class AgentManager { sealed class AgentManager(
IActorRefFactory actorSystem,
AgentConnectionKeys agentConnectionKeys,
ControllerState controllerState,
MinecraftVersions minecraftVersions,
IDbContextProvider dbProvider,
CancellationToken cancellationToken
) {
private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>(); private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>();
private readonly IActorRefFactory actorSystem;
private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions;
private readonly UserLoginManager userLoginManager;
private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken;
private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new (); private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new ();
private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
public AgentManager(IActorRefFactory actorSystem, ControllerState controllerState, MinecraftVersions minecraftVersions, UserLoginManager userLoginManager, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
this.actorSystem = actorSystem;
this.controllerState = controllerState;
this.minecraftVersions = minecraftVersions;
this.userLoginManager = userLoginManager;
this.dbProvider = dbProvider;
this.cancellationToken = cancellationToken;
this.addAgentActorFactory = CreateAgentActor;
}
private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, AgentConfiguration agentConfiguration) {
var init = new AgentActor.Init(agentGuid, agentConfiguration, controllerState, minecraftVersions, dbProvider, cancellationToken);
var name = "Agent:" + agentGuid;
return actorSystem.ActorOf(AgentActor.Factory(init), name);
}
public async Task Initialize() { public async Task Initialize() {
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
await Migrate(ctx);
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
var agentGuid = entity.AgentGuid; var agentGuid = entity.AgentGuid;
var agentConfiguration = new AgentConfiguration(entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
if (agentsByAgentGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, agentConfiguration))) { if (AddAgent(loggedInUserGuid: null, agentGuid, entity.Configuration, entity.AuthSecret!, entity.RuntimeInfo)) {
Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", agentConfiguration.AgentName, agentGuid); Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", entity.Name, agentGuid);
} }
} }
} }
public async Task<ImmutableArray<ConfigureInstanceMessage>> RegisterAgent(AgentRegistration registration) { private bool AddAgent(Guid? loggedInUserGuid, Guid agentGuid, AgentConfiguration configuration, AuthSecret authSecret, AgentRuntimeInfo runtimeInfo) {
var agentConfiguration = AgentConfiguration.From(registration.AgentInfo); var init = new AgentActor.Init(loggedInUserGuid, agentGuid, configuration, authSecret, runtimeInfo, agentConnectionKeys, controllerState, minecraftVersions, dbProvider, cancellationToken);
var agentActor = agentsByAgentGuid.GetOrAdd(registration.AgentInfo.AgentGuid, addAgentActorFactory, agentConfiguration); var name = "Agent:" + agentGuid;
return await agentActor.Request(new AgentActor.RegisterCommand(agentConfiguration, registration.JavaRuntimes), cancellationToken); return agentsByAgentGuid.TryAdd(agentGuid, actorSystem.ActorOf(AgentActor.Factory(init), name));
}
private async Task Migrate(ApplicationDbContext ctx) {
List<AgentEntity> agentsWithoutSecrets = await ctx.Agents.Where(static entity => entity.AuthSecret == null).ToListAsync(cancellationToken);
if (agentsWithoutSecrets.Count == 0) {
return;
}
foreach (var entity in agentsWithoutSecrets) {
entity.AuthSecret = AuthSecret.Generate();
}
await ctx.SaveChangesAsync(cancellationToken);
}
public async Task<ImmutableArray<ConfigureInstanceMessage>?> RegisterAgent(Guid agentGuid, AgentRegistration registration) {
if (!agentsByAgentGuid.TryGetValue(agentGuid, out var agentActor)) {
return null;
}
var runtimeInfo = AgentRuntimeInfo.From(registration.AgentInfo);
return await agentActor.Request(new AgentActor.RegisterCommand(runtimeInfo, registration.JavaRuntimes), cancellationToken);
}
public async Task<AuthSecret?> GetAgentAuthSecret(Guid agentGuid) {
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
return await agent.Request(new AgentActor.GetAuthSecretCommand(), cancellationToken);
}
else {
return null;
}
} }
public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) { public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) {
@@ -71,13 +87,31 @@ sealed class AgentManager {
return true; return true;
} }
else { else {
Logger.Warning("Could not deliver command {CommandType} to agent {AgentGuid}, agent not registered.", command.GetType().Name, agentGuid); Logger.Warning("Could not deliver command {CommandType} to unknown agent {AgentGuid}.", command.GetType().Name, agentGuid);
return false; return false;
} }
} }
public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Permission requiredPermission, ImmutableArray<byte> authToken, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> { public Result<CreateOrUpdateAgentResult, UserActionFailure> CreateOrUpdateAgent(LoggedInUser loggedInUser, Guid agentGuid, AgentConfiguration configuration) {
var loggedInUser = userLoginManager.GetLoggedInUser(authToken); if (!loggedInUser.CheckPermission(Permission.ManageAllAgents)) {
return UserActionFailure.NotAuthorized;
}
if (configuration.AgentName.Length == 0) {
return CreateOrUpdateAgentResult.AgentNameMustNotBeEmpty;
}
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
agent.Tell(new AgentActor.ConfigureAgentCommand(loggedInUser.Guid!.Value, configuration));
}
else {
AddAgent(loggedInUser.Guid!.Value, agentGuid, configuration, AuthSecret.Generate(), new AgentRuntimeInfo());
}
return CreateOrUpdateAgentResult.Success;
}
public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(LoggedInUser loggedInUser, Permission requiredPermission, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) { if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) {
return (UserInstanceActionFailure) UserActionFailure.NotAuthorized; return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
} }

View File

@@ -8,8 +8,10 @@ using Phantom.Controller.Services.Rpc;
using Phantom.Controller.Services.Users; using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Sessions; using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using IRpcAgentRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent, Phantom.Common.Data.Agent.AgentInfo>; using Phantom.Utils.Rpc.Runtime.Server;
using IRpcWebRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb, Phantom.Utils.Rpc.Runtime.Server.RpcServerClientHandshake.NoValue>; using Phantom.Utils.Rpc.Runtime.Tls;
using IRpcAgentRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent>;
using IRpcWebRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb>;
namespace Phantom.Controller.Services; namespace Phantom.Controller.Services;
@@ -32,14 +34,15 @@ public sealed class ControllerServices : IDisposable {
private AuditLogManager AuditLogManager { get; } private AuditLogManager AuditLogManager { get; }
private EventLogManager EventLogManager { get; } private EventLogManager EventLogManager { get; }
public IRpcServerClientAuthProvider AgentAuthProvider { get; }
public IRpcServerClientHandshake AgentHandshake { get; }
public IRpcAgentRegistrar AgentRegistrar { get; } public IRpcAgentRegistrar AgentRegistrar { get; }
public AgentClientHandshake AgentHandshake { get; }
public IRpcWebRegistrar WebRegistrar { get; } public IRpcWebRegistrar WebRegistrar { get; }
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public ControllerServices(IDbContextProvider dbProvider, CancellationToken shutdownCancellationToken) { public ControllerServices(IDbContextProvider dbProvider, RpcCertificateThumbprint agentCertificateThumbprint, CancellationToken shutdownCancellationToken) {
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
this.cancellationToken = shutdownCancellationToken; this.cancellationToken = shutdownCancellationToken;
@@ -55,14 +58,15 @@ public sealed class ControllerServices : IDisposable {
this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, dbProvider); this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, dbProvider);
this.PermissionManager = new PermissionManager(dbProvider); this.PermissionManager = new PermissionManager(dbProvider);
this.AgentManager = new AgentManager(ActorSystem, ControllerState, MinecraftVersions, UserLoginManager, dbProvider, cancellationToken); this.AgentManager = new AgentManager(ActorSystem, new AgentConnectionKeys(agentCertificateThumbprint), ControllerState, MinecraftVersions, dbProvider, cancellationToken);
this.InstanceLogManager = new InstanceLogManager(); this.InstanceLogManager = new InstanceLogManager();
this.AuditLogManager = new AuditLogManager(dbProvider); this.AuditLogManager = new AuditLogManager(dbProvider);
this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken); this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken);
this.AgentRegistrar = new AgentClientRegistrar(ActorSystem, AgentManager, InstanceLogManager, EventLogManager); this.AgentAuthProvider = new AgentClientAuthProvider(AgentManager);
this.AgentHandshake = new AgentClientHandshake(AgentManager); this.AgentHandshake = new AgentClientHandshake(AgentManager);
this.AgentRegistrar = new AgentClientRegistrar(ActorSystem, AgentManager, InstanceLogManager, EventLogManager);
this.WebRegistrar = new WebClientRegistrar(ActorSystem, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager); this.WebRegistrar = new WebClientRegistrar(ActorSystem, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager);
} }

View File

@@ -1,4 +1,5 @@
using Phantom.Common.Data.Web.EventLog; using Akka.Actor;
using Phantom.Common.Data.Web.EventLog;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Repositories; using Phantom.Controller.Database.Repositories;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -7,7 +8,7 @@ using Serilog;
namespace Phantom.Controller.Services.Events; namespace Phantom.Controller.Services.Events;
sealed class EventLogDatabaseStorageActor : ReceiveActor<EventLogDatabaseStorageActor.ICommand> { sealed class EventLogDatabaseStorageActor : ReceiveActor<EventLogDatabaseStorageActor.ICommand>, IWithTimers {
private static readonly ILogger Logger = PhantomLogger.Create<EventLogDatabaseStorageActor>(); private static readonly ILogger Logger = PhantomLogger.Create<EventLogDatabaseStorageActor>();
public readonly record struct Init(IDbContextProvider DbProvider, CancellationToken CancellationToken); public readonly record struct Init(IDbContextProvider DbProvider, CancellationToken CancellationToken);
@@ -16,6 +17,8 @@ sealed class EventLogDatabaseStorageActor : ReceiveActor<EventLogDatabaseStorage
return Props<ICommand>.Create(() => new EventLogDatabaseStorageActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume }); return Props<ICommand>.Create(() => new EventLogDatabaseStorageActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume });
} }
public ITimerScheduler Timers { get; set; } = null!;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
@@ -71,7 +74,7 @@ sealed class EventLogDatabaseStorageActor : ReceiveActor<EventLogDatabaseStorage
private void ScheduleFlush(TimeSpan delay) { private void ScheduleFlush(TimeSpan delay) {
if (!hasScheduledFlush) { if (!hasScheduledFlush) {
hasScheduledFlush = true; hasScheduledFlush = true;
Context.System.Scheduler.ScheduleTellOnce(delay, Self, new FlushChangesCommand(), Self); Timers.StartSingleTimer("FlushChanges", new FlushChangesCommand(), delay, Self);
} }
} }
} }

View File

@@ -0,0 +1,11 @@
using Phantom.Controller.Services.Agents;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc;
sealed class AgentClientAuthProvider(AgentManager agentManager) : IRpcServerClientAuthProvider {
public Task<AuthSecret?> GetAuthSecret(Guid agentGuid) {
return agentManager.GetAgentAuthSecret(agentGuid);
}
}

View File

@@ -1,5 +1,4 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent.Handshake; using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
@@ -10,7 +9,7 @@ using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo> { sealed class AgentClientHandshake : IRpcServerClientHandshake {
private const int MaxRegistrationBytes = 1024 * 1024 * 8; private const int MaxRegistrationBytes = 1024 * 1024 * 8;
private readonly AgentManager agentManager; private readonly AgentManager agentManager;
@@ -19,9 +18,9 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
this.agentManager = agentManager; this.agentManager = agentManager;
} }
public async Task<Either<AgentInfo, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken) { public async Task Perform(bool isNewSession, RpcStream stream, Guid agentGuid, CancellationToken cancellationToken) {
RegistrationResult registrationResult; RegistrationResult registrationResult;
switch (await RegisterAgent(stream, cancellationToken)) { switch (await RegisterAgent(stream, agentGuid, cancellationToken)) {
case Left<RegistrationResult, Exception>(var result): case Left<RegistrationResult, Exception>(var result):
await stream.WriteByte(value: 1, cancellationToken); await stream.WriteByte(value: 1, cancellationToken);
registrationResult = result; registrationResult = result;
@@ -29,11 +28,11 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
case Right<RegistrationResult, Exception>(var exception): case Right<RegistrationResult, Exception>(var exception):
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: 0, cancellationToken);
return Either.Right(exception); throw exception;
default: default:
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: 0, cancellationToken);
return Either.Right<Exception>(new InvalidOperationException("Invalid result type.")); throw new InvalidOperationException("Invalid result type.");
} }
if (isNewSession) { if (isNewSession) {
@@ -50,11 +49,9 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
} }
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
return Either.Left(registrationResult.AgentInfo);
} }
private async Task<Either<RegistrationResult, Exception>> RegisterAgent(RpcStream stream, CancellationToken cancellationToken) { private async Task<Either<RegistrationResult, Exception>> RegisterAgent(RpcStream stream, Guid agentGuid, CancellationToken cancellationToken) {
int serializedRegistrationLength = await stream.ReadSignedInt(cancellationToken); int serializedRegistrationLength = await stream.ReadSignedInt(cancellationToken);
if (serializedRegistrationLength is < 0 or > MaxRegistrationBytes) { if (serializedRegistrationLength is < 0 or > MaxRegistrationBytes) {
return Either.Right<Exception>(new InvalidOperationException("Registration must be between 0 and " + MaxRegistrationBytes + " bytes.")); return Either.Right<Exception>(new InvalidOperationException("Registration must be between 0 and " + MaxRegistrationBytes + " bytes."));
@@ -69,9 +66,13 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
return Either.Right<Exception>(new InvalidOperationException("Caught exception during deserialization.", e)); return Either.Right<Exception>(new InvalidOperationException("Caught exception during deserialization.", e));
} }
var configureInstanceMessages = await agentManager.RegisterAgent(registration); var configureInstanceMessages = await agentManager.RegisterAgent(agentGuid, registration);
return Either.Left(new RegistrationResult(registration.AgentInfo, configureInstanceMessages)); if (configureInstanceMessages == null) {
return Either.Right<Exception>(new InvalidOperationException("Could not register agent."));
}
return Either.Left(new RegistrationResult(configureInstanceMessages.Value));
} }
private readonly record struct RegistrationResult(AgentInfo AgentInfo, ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages); private readonly record struct RegistrationResult(ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages);
} }

View File

@@ -1,7 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
using Phantom.Controller.Services.Events; using Phantom.Controller.Services.Events;
@@ -12,20 +10,29 @@ using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
sealed class AgentClientRegistrar( sealed class AgentClientRegistrar : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent> {
IActorRefFactory actorSystem, private readonly IActorRefFactory actorSystem;
AgentManager agentManager, private readonly AgentManager agentManager;
InstanceLogManager instanceLogManager, private readonly InstanceLogManager instanceLogManager;
EventLogManager eventLogManager private readonly EventLogManager eventLogManager;
) : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent, AgentInfo> {
private readonly Func<Guid, Guid, Receiver> receiverFactory;
private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new (); private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new ();
[SuppressMessage("ReSharper", "LambdaShouldNotCaptureContext")] public AgentClientRegistrar(IActorRefFactory actorSystem, AgentManager agentManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager) {
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection, AgentInfo handshakeResult) { this.actorSystem = actorSystem;
var agentGuid = handshakeResult.AgentGuid; this.agentManager = agentManager;
this.instanceLogManager = instanceLogManager;
this.eventLogManager = eventLogManager;
this.receiverFactory = CreateReceiver;
}
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection) {
Guid agentGuid = connection.ClientGuid;
agentManager.TellAgent(agentGuid, new AgentActor.SetConnectionCommand(connection)); agentManager.TellAgent(agentGuid, new AgentActor.SetConnectionCommand(connection));
var receiver = receiversBySessionGuid.GetOrAdd(connection.SessionId, CreateReceiver, agentGuid); var receiver = receiversBySessionGuid.GetOrAdd(connection.SessionGuid, receiverFactory, agentGuid);
if (receiver.AgentGuid != agentGuid) { if (receiver.AgentGuid != agentGuid) {
throw new InvalidOperationException("Cannot register two agents to the same session!"); throw new InvalidOperationException("Cannot register two agents to the same session!");
} }
@@ -33,8 +40,8 @@ sealed class AgentClientRegistrar(
return receiver; return receiver;
} }
private Receiver CreateReceiver(Guid sessionId, Guid agentGuid) { private Receiver CreateReceiver(Guid sessionGuid, Guid agentGuid) {
var name = "AgentClient-" + sessionId; var name = "AgentClient-" + sessionGuid;
var init = new AgentMessageHandlerActor.Init(agentGuid, agentManager, instanceLogManager, eventLogManager); var init = new AgentMessageHandlerActor.Init(agentGuid, agentManager, instanceLogManager, eventLogManager);
return new Receiver(agentGuid, agentManager, actorSystem.ActorOf(AgentMessageHandlerActor.Factory(init), name)); return new Receiver(agentGuid, agentManager, actorSystem.ActorOf(AgentMessageHandlerActor.Factory(init), name));
} }

View File

@@ -25,30 +25,30 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
this.instanceLogManager = init.InstanceLogManager; this.instanceLogManager = init.InstanceLogManager;
this.eventLogManager = init.EventLogManager; this.eventLogManager = init.EventLogManager;
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus); Receive<ReportAgentStatusMessage>(ReportAgentStatus);
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus); Receive<ReportInstanceStatusMessage>(ReportInstanceStatus);
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts); Receive<ReportInstancePlayerCountsMessage>(ReportInstancePlayerCounts);
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent); Receive<ReportInstanceEventMessage>(ReportInstanceEvent);
Receive<InstanceOutputMessage>(HandleInstanceOutput); Receive<InstanceOutputMessage>(InstanceOutput);
} }
private void HandleReportAgentStatus(ReportAgentStatusMessage message) { private void ReportAgentStatus(ReportAgentStatusMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory));
} }
private void HandleReportInstanceStatus(ReportInstanceStatusMessage message) { private void ReportInstanceStatus(ReportInstanceStatusMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
} }
private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) { private void ReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
} }
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) { private void ReportInstanceEvent(ReportInstanceEventMessage message) {
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid)); message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
} }
private void HandleInstanceOutput(InstanceOutputMessage message) { private void InstanceOutput(InstanceOutputMessage message) {
instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines); instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines);
} }
} }

View File

@@ -0,0 +1,10 @@
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc;
public sealed class WebClientAuthProvider(AuthToken webAuthToken) : IRpcServerClientAuthProvider {
public Task<AuthSecret?> GetAuthSecret(Guid clientGuid) {
return Task.FromResult(clientGuid == webAuthToken.Guid ? webAuthToken.Secret : null);
}
}

View File

@@ -24,9 +24,9 @@ sealed class WebClientRegistrar(
AgentManager agentManager, AgentManager agentManager,
MinecraftVersions minecraftVersions, MinecraftVersions minecraftVersions,
EventLogManager eventLogManager EventLogManager eventLogManager
) : IRpcServerClientRegistrar<IMessageToController, IMessageToWeb, RpcServerClientHandshake.NoValue> { ) : IRpcServerClientRegistrar<IMessageToController, IMessageToWeb> {
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToWeb> connection, RpcServerClientHandshake.NoValue handshakeResult) { public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToWeb> connection) {
var name = "WebClient-" + connection.SessionId; var name = "WebClient-" + connection.SessionGuid;
var init = new WebMessageHandlerActor.Init(connection, controllerState, instanceLogManager, userManager, roleManager, userRoleManager, userLoginManager, auditLogManager, agentManager, minecraftVersions, eventLogManager); var init = new WebMessageHandlerActor.Init(connection, controllerState, instanceLogManager, userManager, roleManager, userRoleManager, userLoginManager, auditLogManager, agentManager, minecraftVersions, eventLogManager);
return new IMessageReceiver<IMessageToController>.Actor(actorSystem.ActorOf(WebMessageHandlerActor.Factory(init), name)); return new IMessageReceiver<IMessageToController>.Actor(actorSystem.ActorOf(WebMessageHandlerActor.Factory(init), name));
} }

View File

@@ -3,6 +3,7 @@ using Phantom.Common.Data;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -63,31 +64,32 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
var senderActorInit = new WebMessageDataUpdateSenderActor.Init(init.Connection.MessageSender, controllerState, init.InstanceLogManager); var senderActorInit = new WebMessageDataUpdateSenderActor.Init(init.Connection.MessageSender, controllerState, init.InstanceLogManager);
Context.ActorOf(WebMessageDataUpdateSenderActor.Factory(senderActorInit), "DataUpdateSender"); Context.ActorOf(WebMessageDataUpdateSenderActor.Factory(senderActorInit), "DataUpdateSender");
ReceiveAndReplyLater<LogInMessage, Optional<LogInSuccess>>(HandleLogIn); ReceiveAndReplyLater<LogInMessage, Optional<LogInSuccess>>(LogIn);
Receive<LogOutMessage>(HandleLogOut); Receive<LogOutMessage>(LogOut);
ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser); ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser);
ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(HandleCreateOrUpdateAdministratorUser); ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(CreateOrUpdateAdministratorUser);
ReceiveAndReplyLater<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(HandleCreateUser); ReceiveAndReplyLater<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(CreateUser);
ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(HandleGetUsers); ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(GetUsers);
ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(HandleGetRoles); ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(GetRoles);
ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(HandleGetUserRoles); ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(GetUserRoles);
ReceiveAndReplyLater<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(HandleChangeUserRoles); ReceiveAndReplyLater<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(ChangeUserRoles);
ReceiveAndReplyLater<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(HandleDeleteUser); ReceiveAndReplyLater<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(DeleteUser);
ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(HandleCreateOrUpdateInstance); ReceiveAndReply<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>(CreateOrUpdateAgentMessage);
ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(HandleLaunchInstance); ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(GetAgentJavaRuntimes);
ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(HandleStopInstance); ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(CreateOrUpdateInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(HandleSendCommandToInstance); ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(LaunchInstance);
ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(HandleGetMinecraftVersions); ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(StopInstance);
ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(HandleGetAgentJavaRuntimes); ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(SendCommandToInstance);
ReceiveAndReplyLater<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(HandleGetAuditLog); ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(GetMinecraftVersions);
ReceiveAndReplyLater<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(HandleGetEventLog); ReceiveAndReplyLater<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(GetAuditLog);
ReceiveAndReplyLater<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(GetEventLog);
} }
private Task<Optional<LogInSuccess>> HandleLogIn(LogInMessage message) { private Task<Optional<LogInSuccess>> LogIn(LogInMessage message) {
return userLoginManager.LogIn(message.Username, message.Password); return userLoginManager.LogIn(message.Username, message.Password);
} }
private void HandleLogOut(LogOutMessage message) { private void LogOut(LogOutMessage message) {
_ = userLoginManager.LogOut(message.UserGuid, message.SessionToken); _ = userLoginManager.LogOut(message.UserGuid, message.SessionToken);
} }
@@ -95,83 +97,87 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.AuthToken); return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.AuthToken);
} }
private Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) { private Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) {
return userManager.CreateOrUpdateAdministrator(message.Username, message.Password); return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
} }
private Task<Result<CreateUserResult, UserActionFailure>> HandleCreateUser(CreateUserMessage message) { private Task<Result<CreateUserResult, UserActionFailure>> CreateUser(CreateUserMessage message) {
return userManager.Create(userLoginManager.GetLoggedInUser(message.AuthToken), message.Username, message.Password); return userManager.Create(userLoginManager.GetLoggedInUser(message.AuthToken), message.Username, message.Password);
} }
private Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) { private Task<ImmutableArray<UserInfo>> GetUsers(GetUsersMessage message) {
return userManager.GetAll(); return userManager.GetAll();
} }
private Task<ImmutableArray<RoleInfo>> HandleGetRoles(GetRolesMessage message) { private Task<ImmutableArray<RoleInfo>> GetRoles(GetRolesMessage message) {
return roleManager.GetAll(); return roleManager.GetAll();
} }
private Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> HandleGetUserRoles(GetUserRolesMessage message) { private Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetUserRoles(GetUserRolesMessage message) {
return userRoleManager.GetUserRoles(message.UserGuids); return userRoleManager.GetUserRoles(message.UserGuids);
} }
private Task<Result<ChangeUserRolesResult, UserActionFailure>> HandleChangeUserRoles(ChangeUserRolesMessage message) { private Task<Result<ChangeUserRolesResult, UserActionFailure>> ChangeUserRoles(ChangeUserRolesMessage message) {
return userRoleManager.ChangeUserRoles(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids); return userRoleManager.ChangeUserRoles(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids);
} }
private Task<Result<DeleteUserResult, UserActionFailure>> HandleDeleteUser(DeleteUserMessage message) { private Task<Result<DeleteUserResult, UserActionFailure>> DeleteUser(DeleteUserMessage message) {
return userManager.DeleteByGuid(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid); return userManager.DeleteByGuid(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid);
} }
private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) { private Result<CreateOrUpdateAgentResult, UserActionFailure> CreateOrUpdateAgentMessage(CreateOrUpdateAgentMessage message) {
return agentManager.CreateOrUpdateAgent(userLoginManager.GetLoggedInUser(message.AuthToken), message.AgentGuid, message.Configuration);
}
private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> GetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) {
return controllerState.AgentJavaRuntimesByGuid;
}
private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> CreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>( return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.CreateInstances, Permission.CreateInstances,
message.AuthToken,
message.Configuration.AgentGuid, message.Configuration.AgentGuid,
loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration) loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration)
); );
} }
private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) { private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> LaunchInstance(LaunchInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>( return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.ControlInstances, Permission.ControlInstances,
message.AuthToken,
message.AgentGuid, message.AgentGuid,
loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid) loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid)
); );
} }
private Task<Result<StopInstanceResult, UserInstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) { private Task<Result<StopInstanceResult, UserInstanceActionFailure>> StopInstance(StopInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>( return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.ControlInstances, Permission.ControlInstances,
message.AuthToken,
message.AgentGuid, message.AgentGuid,
loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy) loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy)
); );
} }
private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> SendCommandToInstance(SendCommandToInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>( return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.ControlInstances, Permission.ControlInstances,
message.AuthToken,
message.AgentGuid, message.AgentGuid,
loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command) loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command)
); );
} }
private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) { private Task<ImmutableArray<MinecraftVersion>> GetMinecraftVersions(GetMinecraftVersionsMessage message) {
return minecraftVersions.GetVersions(CancellationToken.None); return minecraftVersions.GetVersions(CancellationToken.None);
} }
private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) { private Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> GetAuditLog(GetAuditLogMessage message) {
return controllerState.AgentJavaRuntimesByGuid;
}
private Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> HandleGetAuditLog(GetAuditLogMessage message) {
return auditLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count); return auditLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
} }
private Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> HandleGetEventLog(GetEventLogMessage message) { private Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> GetEventLog(GetEventLogMessage message) {
return eventLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count); return eventLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
} }
} }

View File

@@ -0,0 +1,77 @@
using Phantom.Common.Data;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog;
namespace Phantom.Controller;
abstract class AuthTokenFile {
private static ILogger Logger { get; } = PhantomLogger.Create<AuthTokenFile>();
private readonly string fileName;
private readonly RpcServerCertificate certificate;
private AuthTokenFile(string name, RpcServerCertificate certificate) {
this.fileName = name + ".auth";
this.certificate = certificate;
}
public async Task<ConnectionKey?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, fileName);
if (File.Exists(filePath)) {
try {
return await ReadKeyFiles(filePath);
} catch (IOException e) {
Logger.Fatal(e, "Error reading auth token file: {FileName}", fileName);
return null;
} catch (Exception) {
Logger.Fatal("Auth token file contains invalid data: {FileName}", fileName);
return null;
}
}
try {
return await GenerateKeyFiles(filePath);
} catch (Exception e) {
Logger.Fatal(e, "Error creating auth token file: {FileName}", fileName);
return null;
}
}
private async Task<ConnectionKey?> ReadKeyFiles(string filePath) {
var authToken = AuthToken.FromBytes(await ReadKeyFile(filePath));
Logger.Information("Loaded auth token file: {FileName}", fileName);
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogConnectionKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes().AsSpan()));
return connectionKey;
}
private static Task<byte[]> ReadKeyFile(string filePath) {
Files.RequireMaximumFileSize(filePath, maximumBytes: 64);
return File.ReadAllBytesAsync(filePath);
}
private async Task<ConnectionKey> GenerateKeyFiles(string filePath) {
var authToken = AuthToken.Generate();
await Files.WriteBytesAsync(filePath, authToken.ToBytes().AsMemory(), FileMode.Create, Chmod.URW_GR);
Logger.Information("Created auth token file: {FileName}", fileName);
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogConnectionKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes().AsSpan()));
return connectionKey;
}
protected abstract void LogConnectionKey(string commonKeyEncoded);
internal sealed class Web(string name, RpcServerCertificate certificate) : AuthTokenFile(name, certificate) {
protected override void LogConnectionKey(string commonKeyEncoded) {
Logger.Information("Web key: {WebKey}", commonKeyEncoded);
}
}
}

View File

@@ -0,0 +1,60 @@
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Monads;
using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog;
namespace Phantom.Controller;
sealed class CertificateFile(string name) {
private static ILogger Logger { get; } = PhantomLogger.Create<CertificateFile>();
private readonly string fileName = name + ".pfx";
public async Task<RpcServerCertificate?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, fileName);
if (File.Exists(filePath)) {
try {
return Read(filePath);
} catch (IOException e) {
Logger.Fatal(e, "Error reading certificate file: {FileName}", fileName);
return null;
} catch (Exception) {
Logger.Fatal("Certificate file contains invalid data: {FileName}", fileName);
return null;
}
}
try {
return await Generate(filePath);
} catch (Exception e) {
Logger.Fatal(e, "Error creating certificate file: {FileName}", fileName);
return null;
}
}
private RpcServerCertificate? Read(string filePath) {
switch (RpcServerCertificate.Load(filePath)) {
case Left<RpcServerCertificate, DisallowedAlgorithmError>(var rpcServerCertificate):
Logger.Information("Loaded certificate file: {FileName}", fileName);
return rpcServerCertificate;
case Right<RpcServerCertificate, DisallowedAlgorithmError>(var error):
Logger.Fatal("Certificate file {FileName} was expected to use {ExpectedAlgorithmName}, instead it uses {ActualAlgorithmName}.", fileName, error.ExpectedAlgorithmName, error.ActualAlgorithmName);
return null;
}
Logger.Fatal("Certificate file could not be loaded: {FileName}", fileName);
return null;
}
private async Task<RpcServerCertificate> Generate(string filePath) {
byte[] certificateBytes = RpcServerCertificate.CreateAndExport("phantom-controller");
await Files.WriteBytesAsync(filePath, certificateBytes, FileMode.Create, Chmod.URW_GR);
Logger.Information("Created certificate file: {FileName}", fileName);
return RpcServerCertificate.Load(filePath).RequireLeft;
}
}

View File

@@ -1,6 +0,0 @@
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Controller;
readonly record struct ConnectionKeyData(RpcServerCertificate Certificate, AuthToken AuthToken);

View File

@@ -1,114 +0,0 @@
using Phantom.Common.Data;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Monads;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog;
namespace Phantom.Controller;
abstract class ConnectionKeyFiles {
private readonly ILogger logger;
private readonly string certificateFileName;
private readonly string authTokenFileName;
private ConnectionKeyFiles(ILogger logger, string name) {
this.logger = logger;
this.certificateFileName = name + ".pfx";
this.authTokenFileName = name + ".auth";
}
public async Task<ConnectionKeyData?> CreateOrLoad(string folderPath) {
string certificateFilePath = Path.Combine(folderPath, certificateFileName);
string authTokenFilePath = Path.Combine(folderPath, authTokenFileName);
bool certificateFileExists = File.Exists(certificateFilePath);
bool authTokenFileExists = File.Exists(authTokenFilePath);
if (certificateFileExists && authTokenFileExists) {
try {
return await ReadKeyFiles(certificateFilePath, authTokenFilePath);
} catch (IOException e) {
logger.Fatal(e, "Error reading connection key files.");
return null;
} catch (Exception) {
logger.Fatal("Connection key files contain invalid data.");
return null;
}
}
if (certificateFileExists || authTokenFileExists) {
string existingKeyFilePath = certificateFileExists ? certificateFilePath : authTokenFilePath;
string missingKeyFileName = certificateFileExists ? authTokenFileName : certificateFileName;
logger.Fatal("Connection key file {ExistingKeyFilePath} exists but {MissingKeyFileName} does not. Please delete it to regenerate both files.", existingKeyFilePath, missingKeyFileName);
return null;
}
logger.Information("Creating connection key files in: {FolderPath}", folderPath);
try {
return await GenerateKeyFiles(certificateFilePath, authTokenFilePath);
} catch (Exception e) {
logger.Fatal(e, "Error creating connection key files.");
return null;
}
}
private async Task<ConnectionKeyData?> ReadKeyFiles(string certificateFilePath, string authTokenFilePath) {
RpcServerCertificate certificate = null!;
switch (RpcServerCertificate.Load(certificateFilePath)) {
case Left<RpcServerCertificate, DisallowedAlgorithmError>(var rpcServerCertificate):
certificate = rpcServerCertificate;
break;
case Right<RpcServerCertificate, DisallowedAlgorithmError>(var error):
logger.Fatal("Certificate {CertificateFilePath} was expected to use {ExpectedAlgorithmName}, instead it uses {ActualAlgorithmName}.", certificateFilePath, error.ExpectedAlgorithmName, error.ActualAlgorithmName);
return null;
}
var authToken = new AuthToken([..await ReadKeyFile(authTokenFilePath)]);
logger.Information("Loaded connection key files.");
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogCommonKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes()));
return new ConnectionKeyData(certificate, authToken);
}
private static Task<byte[]> ReadKeyFile(string filePath) {
Files.RequireMaximumFileSize(filePath, maximumBytes: 64);
return File.ReadAllBytesAsync(filePath);
}
private async Task<ConnectionKeyData> GenerateKeyFiles(string certificateFilePath, string authTokenFilePath) {
var certificateBytes = RpcServerCertificate.CreateAndExport("phantom-controller");
var authToken = AuthToken.Generate();
await Files.WriteBytesAsync(certificateFilePath, certificateBytes, FileMode.Create, Chmod.URW_GR);
await Files.WriteBytesAsync(authTokenFilePath, authToken.Bytes.ToArray(), FileMode.Create, Chmod.URW_GR);
logger.Information("Created new connection key files.");
var certificate = RpcServerCertificate.Load(certificateFilePath).RequireLeft;
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogCommonKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes()));
return new ConnectionKeyData(certificate, authToken);
}
protected abstract void LogCommonKey(string commonKeyEncoded);
internal sealed class Agent() : ConnectionKeyFiles(PhantomLogger.Create<ConnectionKeyFiles, Agent>(), "agent") {
protected override void LogCommonKey(string commonKeyEncoded) {
logger.Information("Agent key: {AgentKey}", commonKeyEncoded);
}
}
internal sealed class Web() : ConnectionKeyFiles(PhantomLogger.Create<ConnectionKeyFiles, Web>(), "web") {
protected override void LogCommonKey(string commonKeyEncoded) {
logger.Information("Web key: {WebKey}", commonKeyEncoded);
}
}
}

View File

@@ -4,13 +4,14 @@ using Phantom.Common.Messages.Web;
using Phantom.Controller; using Phantom.Controller;
using Phantom.Controller.Database.Postgres; using Phantom.Controller.Database.Postgres;
using Phantom.Controller.Services; using Phantom.Controller.Services;
using Phantom.Controller.Services.Rpc;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime.Server; using Phantom.Utils.Rpc.Runtime.Server;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using RpcAgentServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent, Phantom.Common.Data.Agent.AgentInfo>; using RpcAgentServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent>;
using RpcWebServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb, Phantom.Utils.Rpc.Runtime.Server.RpcServerClientHandshake.NoValue>; using RpcWebServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb>;
var shutdownCancellationTokenSource = new CancellationTokenSource(); var shutdownCancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token; var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
@@ -43,12 +44,17 @@ try {
string secretsPath = Path.GetFullPath("./secrets"); string secretsPath = Path.GetFullPath("./secrets");
CreateFolderOrStop(secretsPath, Chmod.URWX_GRX); CreateFolderOrStop(secretsPath, Chmod.URWX_GRX);
var agentKeyDataResult = await new ConnectionKeyFiles.Agent().CreateOrLoad(secretsPath); var agentCertificate = await new CertificateFile("agent").CreateOrLoad(secretsPath);
if (agentKeyDataResult is not {} agentKeyData) { if (agentCertificate == null) {
return 1; return 1;
} }
var webKeyDataResult = await new ConnectionKeyFiles.Web().CreateOrLoad(secretsPath); var webCertificate = await new CertificateFile("web").CreateOrLoad(secretsPath);
if (webCertificate == null) {
return 1;
}
var webKeyDataResult = await new AuthTokenFile.Web("web", webCertificate).CreateOrLoad(secretsPath);
if (webKeyDataResult is not {} webKeyData) { if (webKeyDataResult is not {} webKeyData) {
return 1; return 1;
} }
@@ -57,13 +63,12 @@ try {
var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString); var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
using var controllerServices = new ControllerServices(dbContextFactory, shutdownCancellationToken); using var controllerServices = new ControllerServices(dbContextFactory, agentCertificate.Thumbprint, shutdownCancellationToken);
await controllerServices.Initialize(); await controllerServices.Initialize();
var agentConnectionParameters = new RpcServerConnectionParameters( var agentConnectionParameters = new RpcServerConnectionParameters(
EndPoint: agentRpcServerHost, EndPoint: agentRpcServerHost,
Certificate: agentKeyData.Certificate, Certificate: agentCertificate,
AuthToken: agentKeyData.AuthToken,
PingIntervalSeconds: 10, PingIntervalSeconds: 10,
MessageQueueCapacity: 50, MessageQueueCapacity: 50,
FrameQueueCapacity: 100, FrameQueueCapacity: 100,
@@ -72,17 +77,18 @@ try {
var webConnectionParameters = new RpcServerConnectionParameters( var webConnectionParameters = new RpcServerConnectionParameters(
EndPoint: webRpcServerHost, EndPoint: webRpcServerHost,
Certificate: webKeyData.Certificate, Certificate: webCertificate,
AuthToken: webKeyData.AuthToken,
PingIntervalSeconds: 60, PingIntervalSeconds: 60,
MessageQueueCapacity: 250, MessageQueueCapacity: 250,
FrameQueueCapacity: 500, FrameQueueCapacity: 500,
MaxConcurrentlyHandledMessages: 100 MaxConcurrentlyHandledMessages: 100
); );
var webClientAuthProvider = new WebClientAuthProvider(webKeyData.AuthToken);
var rpcServerTasks = new LinkedTasks<bool>([ var rpcServerTasks = new LinkedTasks<bool>([
new RpcAgentServer("Agent", agentConnectionParameters, AgentMessageRegistries.Registries, controllerServices.AgentHandshake, controllerServices.AgentRegistrar).Run(shutdownCancellationToken), new RpcAgentServer("Agent", agentConnectionParameters, AgentMessageRegistries.Registries, controllerServices.AgentAuthProvider, controllerServices.AgentHandshake, controllerServices.AgentRegistrar).Run(shutdownCancellationToken),
new RpcWebServer("Web", webConnectionParameters, WebMessageRegistries.Registries, new RpcServerClientHandshake.NoOp(), controllerServices.WebRegistrar).Run(shutdownCancellationToken), new RpcWebServer("Web", webConnectionParameters, WebMessageRegistries.Registries, webClientAuthProvider, new IRpcServerClientHandshake.NoOp(), controllerServices.WebRegistrar).Run(shutdownCancellationToken),
]); ]);
// If either RPC server crashes, stop the whole process. // If either RPC server crashes, stop the whole process.

View File

@@ -1,23 +1,19 @@
<Project> <Project>
<ItemGroup> <ItemGroup>
<PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="8.0.0" /> <PackageReference Update="Microsoft.AspNetCore.Components.Authorization" Version="9.0.9" />
<PackageReference Update="Microsoft.AspNetCore.Components.Web" Version="8.0.0" /> <PackageReference Update="Microsoft.AspNetCore.Components.Web" Version="9.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Microsoft.EntityFrameworkCore.Relational" Version="8.0.0" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9" />
<PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" /> <PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Update="System.Linq.Async" Version="6.0.1" /> <PackageReference Update="System.Linq.Async" Version="6.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Kajabity.Tools.Java" Version="0.3.8607.38728" /> <PackageReference Update="Akka" Version="1.5.51" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Akka" Version="1.5.17.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -26,10 +22,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Update="Serilog" Version="3.1.1" /> <PackageReference Update="Serilog" Version="4.3.0" />
<PackageReference Update="Serilog.AspNetCore" Version="8.0.0" /> <PackageReference Update="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Update="Serilog.Sinks.Async" Version="1.5.0" /> <PackageReference Update="Serilog.Sinks.Async" Version="2.1.0" />
<PackageReference Update="Serilog.Sinks.Console" Version="5.0.1" /> <PackageReference Update="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{F5878792-64C8-4ECF-A075-66341FF97127}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{F5878792-64C8-4ECF-A075-66341FF97127}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent.Tests", "Agent.Tests", "{94C1E464-3F91-49EA-99FF-3A3082C54CE8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common", "Common", "{01CB1A81-8950-471C-BFDF-F135FDDB2C18}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common", "Common", "{01CB1A81-8950-471C-BFDF-F135FDDB2C18}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common.Tests", "Common.Tests", "{D781E00D-8563-4102-A0CD-477A679193B5}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common.Tests", "Common.Tests", "{D781E00D-8563-4102-A0CD-477A679193B5}"
@@ -18,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Agent", "Agent\Phan
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Agent.Minecraft", "Agent\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj", "{9FE000D0-91AC-4CB4-8956-91CCC0270015}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Agent.Minecraft", "Agent\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj", "{9FE000D0-91AC-4CB4-8956-91CCC0270015}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Agent.Minecraft.Tests", "Agent\Phantom.Agent.Minecraft.Tests\Phantom.Agent.Minecraft.Tests.csproj", "{065FFFA0-DFF4-43DB-AB3D-B92EE9848DDB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Agent.Services", "Agent\Phantom.Agent.Services\Phantom.Agent.Services.csproj", "{AEE8B77E-AB07-423F-9981-8CD829ACB834}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Agent.Services", "Agent\Phantom.Agent.Services\Phantom.Agent.Services.csproj", "{AEE8B77E-AB07-423F-9981-8CD829ACB834}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data", "Common\Phantom.Common.Data\Phantom.Common.Data.csproj", "{6C3DB1E5-F695-4D70-8F3A-78C2957274BE}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data", "Common\Phantom.Common.Data\Phantom.Common.Data.csproj", "{6C3DB1E5-F695-4D70-8F3A-78C2957274BE}"
@@ -74,6 +78,10 @@ Global
{9FE000D0-91AC-4CB4-8956-91CCC0270015}.Debug|Any CPU.Build.0 = Debug|Any CPU {9FE000D0-91AC-4CB4-8956-91CCC0270015}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9FE000D0-91AC-4CB4-8956-91CCC0270015}.Release|Any CPU.ActiveCfg = Release|Any CPU {9FE000D0-91AC-4CB4-8956-91CCC0270015}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9FE000D0-91AC-4CB4-8956-91CCC0270015}.Release|Any CPU.Build.0 = Release|Any CPU {9FE000D0-91AC-4CB4-8956-91CCC0270015}.Release|Any CPU.Build.0 = Release|Any CPU
{065FFFA0-DFF4-43DB-AB3D-B92EE9848DDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{065FFFA0-DFF4-43DB-AB3D-B92EE9848DDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{065FFFA0-DFF4-43DB-AB3D-B92EE9848DDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{065FFFA0-DFF4-43DB-AB3D-B92EE9848DDB}.Release|Any CPU.Build.0 = Release|Any CPU
{AEE8B77E-AB07-423F-9981-8CD829ACB834}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AEE8B77E-AB07-423F-9981-8CD829ACB834}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AEE8B77E-AB07-423F-9981-8CD829ACB834}.Debug|Any CPU.Build.0 = Debug|Any CPU {AEE8B77E-AB07-423F-9981-8CD829ACB834}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AEE8B77E-AB07-423F-9981-8CD829ACB834}.Release|Any CPU.ActiveCfg = Release|Any CPU {AEE8B77E-AB07-423F-9981-8CD829ACB834}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -158,6 +166,7 @@ Global
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{418BE1BF-9F63-4B46-B4E4-DF64C3B3DDA7} = {F5878792-64C8-4ECF-A075-66341FF97127} {418BE1BF-9F63-4B46-B4E4-DF64C3B3DDA7} = {F5878792-64C8-4ECF-A075-66341FF97127}
{9FE000D0-91AC-4CB4-8956-91CCC0270015} = {F5878792-64C8-4ECF-A075-66341FF97127} {9FE000D0-91AC-4CB4-8956-91CCC0270015} = {F5878792-64C8-4ECF-A075-66341FF97127}
{065FFFA0-DFF4-43DB-AB3D-B92EE9848DDB} = {94C1E464-3F91-49EA-99FF-3A3082C54CE8}
{AEE8B77E-AB07-423F-9981-8CD829ACB834} = {F5878792-64C8-4ECF-A075-66341FF97127} {AEE8B77E-AB07-423F-9981-8CD829ACB834} = {F5878792-64C8-4ECF-A075-66341FF97127}
{6C3DB1E5-F695-4D70-8F3A-78C2957274BE} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {6C3DB1E5-F695-4D70-8F3A-78C2957274BE} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}
{95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18}

View File

@@ -45,11 +45,13 @@ The Controller comprises 3 key areas:
The configuration for these is set via environment variables. The configuration for these is set via environment variables.
### Agent & Web Keys ### Secrets
When the Controller starts for the first time, it will generate two certificate files (`agent.pfx` and `web.pfx`), which are used for TLS communication, and two authentication token files (`agent.auth` and `web.auth`). These files must only be accessible to the Controller itself. Each Agent requires its own **Agent Key**, and the Web server requires a **Web Key**. These must be passed to the services in an environment variable or a file.
On every start, the Controller prints the **Agent Key** and **Web Key** to standard output. These keys contain the authentication token, which lets the Controller validate the identity of the connecting service, and a certificate signature, which lets the connecting service validate the identity of the Controller. The keys must be passed to the Agent and Web services using an environment variable or a file. When the Controller starts for the first time, it will generate two certificate files (`agent.pfx` and `web.pfx`), which are used for TLS communication, and a Web authentication token file (`web.auth`). These files must only be accessible to the Controller itself.
Since there is only one Web server, there is only one **Web Key**, which is generated from the Web certificate and authentication token files. The Controller prints the **Web Key** to standard output on every start. Agents and their **Agent Keys** are managed through the Web interface, and their authentication tokens are stored in the database.
### Storage ### Storage
@@ -86,9 +88,8 @@ Use volumes to persist either the whole `/data` folder, or just `/data/data` if
* **Controller Communication** * **Controller Communication**
- `CONTROLLER_HOST` is the hostname of the Controller. - `CONTROLLER_HOST` is the hostname of the Controller.
- `CONTROLLER_PORT` is the Agent RPC port of the Controller. Default: `9401` - `CONTROLLER_PORT` is the Agent RPC port of the Controller. Default: `9401`
- `AGENT_NAME` is the display name of the Agent. Emoji are allowed. - `AGENT_KEY` is the [Agent Key](#secrets). Mutually exclusive with `AGENT_KEY_FILE`.
- `AGENT_KEY` is the [Agent Key](#agent--web-keys). Mutually exclusive with `AGENT_KEY_FILE`. - `AGENT_KEY_FILE` is a path to a file containing the [Agent Key](#secrets). Mutually exclusive with `AGENT_KEY`.
- `AGENT_KEY_FILE` is a path to a file containing the [Agent Key](#agent--web-keys). Mutually exclusive with `AGENT_KEY`.
* **Agent Configuration** * **Agent Configuration**
- `MAX_INSTANCES` is the number of instances that can be created. - `MAX_INSTANCES` is the number of instances that can be created.
- `MAX_MEMORY` is the maximum amount of RAM that can be distributed among all instances. Use a positive integer with an optional suffix 'M' for MB, or 'G' for GB. Examples: `4096M`, `16G` - `MAX_MEMORY` is the maximum amount of RAM that can be distributed among all instances. Use a positive integer with an optional suffix 'M' for MB, or 'G' for GB. Examples: `4096M`, `16G`
@@ -109,8 +110,8 @@ Use volumes to persist the whole `/data` folder.
* **Controller Communication** * **Controller Communication**
- `CONTROLLER_HOST` is the hostname of the Controller. - `CONTROLLER_HOST` is the hostname of the Controller.
- `CONTROLLER_PORT` is the Web RPC port of the Controller. Default: `9402` - `CONTROLLER_PORT` is the Web RPC port of the Controller. Default: `9402`
- `WEB_KEY` is the [Web Key](#agent--web-keys). Mutually exclusive with `WEB_KEY_FILE`. - `WEB_KEY` is the [Web Key](#secrets). Mutually exclusive with `WEB_KEY_FILE`.
- `WEB_KEY_FILE` is a path to a file containing the [Web Key](#agent--web-keys). Mutually exclusive with `WEB_KEY`. - `WEB_KEY_FILE` is a path to a file containing the [Web Key](#secrets). Mutually exclusive with `WEB_KEY`.
* **Web Server** * **Web Server**
- `WEB_SERVER_HOST` is the host. Default: `0.0.0.0` - `WEB_SERVER_HOST` is the host. Default: `0.0.0.0`
- `WEB_SERVER_PORT` is the port. Default: `9400` - `WEB_SERVER_PORT` is the port. Default: `9400`
@@ -130,7 +131,7 @@ If the environment variable is omitted, the log level is set to `VERBOSE` for De
# Development # Development
The repository includes a [Rider](https://www.jetbrains.com/rider/) projects with several run configurations. The `.workdir` folder in the root of the repository is used for storage. Here's how to get started: The repository includes a [Rider](https://www.jetbrains.com/rider/) projects with several run configurations. The `.workdir` folder in the root of the repository is used for storage, including secret files intended for development use only. Here's how to get started:
1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration: 1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration:
- Host: `localhost` - Host: `localhost`
@@ -139,12 +140,11 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit
- Password: `development` - Password: `development`
- Database: `postgres` - Database: `postgres`
2. Install one or more Java versions into the `~/.jdks` folder (`%USERPROFILE%\.jdks` on Windows). 2. Install one or more Java versions into the `~/.jdks` folder (`%USERPROFILE%\.jdks` on Windows).
3. Open the project in [Rider](https://www.jetbrains.com/rider/) and use one of the provided run configurations: 3. Open the project in [Rider](https://www.jetbrains.com/rider/).
- `Controller` starts the Controller. 4. Launch the `Controller` and `Web` run configurations.
- `Web` starts the Web server. 5. Open the website and create an account.
- `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents. 6. Create 1-3 Agents on the website. For each, create a `.workdir/AgentX/key` file containing the respective Agent Key.
- `Controller + Web + Agent` starts the Controller and Agent 1. 7. Launch any of the `Agent 1`, `Agent 2`, `Agent 3` run configurations.
- `Controller + Web + Agent x3` starts the Controller and Agent 1, 2, and 3.
## Bootstrap ## Bootstrap

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
namespace Phantom.Utils.Rpc;
public sealed class AuthSecret {
public const int Length = 12;
public ImmutableArray<byte> Bytes { get; }
public AuthSecret(ImmutableArray<byte> bytes) {
if (bytes.Length != Length) {
throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid auth secret length: " + bytes.Length + ". Auth secret must be exactly " + Length + " bytes.");
}
this.Bytes = bytes;
}
internal bool FixedTimeEquals(AuthSecret provided) {
return FixedTimeEquals(provided.Bytes.AsSpan());
}
internal bool FixedTimeEquals(ReadOnlySpan<byte> other) {
return CryptographicOperations.FixedTimeEquals(Bytes.AsSpan(), other);
}
public static AuthSecret Generate() {
return new AuthSecret([..RandomNumberGenerator.GetBytes(Length)]);
}
}

View File

@@ -1,30 +1,35 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Security.Cryptography;
namespace Phantom.Utils.Rpc; namespace Phantom.Utils.Rpc;
public sealed class AuthToken { public sealed record AuthToken(Guid Guid, AuthSecret Secret) {
public const int Length = 12; public const int Length = Serialization.GuidBytes + AuthSecret.Length;
public ImmutableArray<byte> Bytes { get; } public ImmutableArray<byte> ToBytes() {
Span<byte> buffer = stackalloc byte[Length];
ToBytes(buffer);
return [..buffer];
}
public AuthToken(ImmutableArray<byte> bytes) { public void ToBytes(Span<byte> buffer) {
Serialization.WriteGuid(buffer, Guid);
Secret.Bytes.CopyTo(buffer[Serialization.GuidBytes..]);
}
public static AuthToken FromBytes(ReadOnlySpan<byte> bytes) {
if (bytes.Length != Length) { if (bytes.Length != Length) {
throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid token length: " + bytes.Length + ". Token length must be exactly " + Length + " bytes."); throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid auth token length: " + bytes.Length + ". Auth token must be exactly " + Length + " bytes.");
} }
this.Bytes = bytes; var guidSpan = bytes[..Serialization.GuidBytes];
} var secretSpan = bytes[Serialization.GuidBytes..];
internal bool FixedTimeEquals(AuthToken providedAuthToken) { var guid = new Guid(guidSpan);
return FixedTimeEquals(providedAuthToken.Bytes.AsSpan()); var secret = new AuthSecret([..secretSpan]);
} return new AuthToken(guid, secret);
public bool FixedTimeEquals(ReadOnlySpan<byte> other) {
return CryptographicOperations.FixedTimeEquals(Bytes.AsSpan(), other);
} }
public static AuthToken Generate() { public static AuthToken Generate() {
return new AuthToken([..RandomNumberGenerator.GetBytes(Length)]); return new AuthToken(Guid.NewGuid(), AuthSecret.Generate());
} }
} }

View File

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

View File

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

View File

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

View File

@@ -142,13 +142,16 @@ public sealed class MessageSender<TMessageBase> {
messageReplyTracker.FailReply(frame.ReplyingToMessageId, MessageErrorException.From(frame.Error)); messageReplyTracker.FailReply(frame.ReplyingToMessageId, MessageErrorException.From(frame.Error));
} }
internal async Task Close() { internal async Task Close(TimeSpan timeout) {
messageQueue.Writer.TryComplete(); messageQueue.Writer.TryComplete();
try { try {
await messageQueueTask.WaitAsync(TimeSpan.FromSeconds(15)); await messageQueueTask.WaitAsync(timeout);
} catch (TimeoutException) { } catch (TimeoutException) {
logger.Warning("Could not finish processing message queue before timeout, forcibly shutting it down."); if (timeout != TimeSpan.Zero) {
logger.Warning("Could not finish processing message queue before timeout, forcibly shutting it down.");
}
await shutdownCancellationTokenSource.CancelAsync(); await shutdownCancellationTokenSource.CancelAsync();
await messageQueueTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); await messageQueueTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
} catch (Exception) { } catch (Exception) {

View File

@@ -133,7 +133,7 @@ public sealed class RpcClient<TClientToServerMessage, TServerToClientMessage> :
} }
} finally { } finally {
if (sessionState.HasValue) { if (sessionState.HasValue) {
await sessionState.Value.TryShutdown(logger, sendSessionTermination: cancellationToken.IsCancellationRequested); await ShutdownSessionState(sessionState.Value);
} }
if (connection != null) { if (connection != null) {
@@ -157,6 +157,15 @@ public sealed class RpcClient<TClientToServerMessage, TServerToClientMessage> :
return new SessionState(frameSender, frameReader); return new SessionState(frameSender, frameReader);
} }
private async Task ShutdownSessionState(SessionState sessionState) {
if (connector.IsEnabled) {
await sessionState.TryShutdown(logger, sendSessionTermination: shutdownCancellationTokenSource.IsCancellationRequested);
}
else {
await sessionState.TryShutdownNow(logger);
}
}
private readonly record struct SessionState(RpcFrameSender<TClientToServerMessage> FrameSender, RpcFrameReader<TClientToServerMessage, TServerToClientMessage> FrameReader) { private readonly record struct SessionState(RpcFrameSender<TClientToServerMessage> FrameSender, RpcFrameReader<TClientToServerMessage, TServerToClientMessage> FrameReader) {
public void Update(ILogger logger, RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>.Connection connection) { public void Update(ILogger logger, RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>.Connection connection) {
TimeSpan currentPingInterval = FrameSender.PingInterval; TimeSpan currentPingInterval = FrameSender.PingInterval;
@@ -186,7 +195,7 @@ public sealed class RpcClient<TClientToServerMessage, TServerToClientMessage> :
logger.Information("Shutting down client..."); logger.Information("Shutting down client...");
try { try {
await MessageSender.Close(); await MessageSender.Close(connector.IsEnabled ? TimeSpan.FromSeconds(15) : TimeSpan.Zero);
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Caught exception while closing message sender."); logger.Error(e, "Caught exception while closing message sender.");
} }

View File

@@ -5,6 +5,7 @@ using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Handshake;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog; using Serilog;
@@ -21,16 +22,19 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
private readonly ILogger logger; private readonly ILogger logger;
private readonly RpcClientConnectionParameters parameters; private readonly RpcClientConnectionParameters parameters;
private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries; private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries;
private readonly Guid sessionId; private readonly Guid sessionGuid;
private readonly SslClientAuthenticationOptions sslOptions; private readonly SslClientAuthenticationOptions sslOptions;
private bool wasRejectedDueToClosedSession = false;
private bool loggedCertificateValidationError = false; private bool loggedCertificateValidationError = false;
internal bool IsEnabled => !wasRejectedDueToClosedSession;
public RpcClientToServerConnector(string loggerName, RpcClientConnectionParameters parameters, MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries) { public RpcClientToServerConnector(string loggerName, RpcClientConnectionParameters parameters, MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries) {
this.logger = PhantomLogger.Create<RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>>(loggerName); this.logger = PhantomLogger.Create<RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>>(loggerName);
this.parameters = parameters; this.parameters = parameters;
this.messageRegistries = messageRegistries; this.messageRegistries = messageRegistries;
this.sessionId = Guid.NewGuid(); this.sessionGuid = Guid.NewGuid();
this.sslOptions = new SslClientAuthenticationOptions { this.sslOptions = new SslClientAuthenticationOptions {
AllowRenegotiation = false, AllowRenegotiation = false,
@@ -57,7 +61,7 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (attempt >= maxAttempts) { if (attempt >= maxAttempts || wasRejectedDueToClosedSession) {
break; break;
} }
@@ -83,6 +87,11 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (wasRejectedDueToClosedSession) {
logger.Warning("A restart will be required to start a new session!");
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
logger.Warning("Retrying in {Seconds}s.", nextAttemptDelay.TotalSeconds.ToString("F1")); logger.Warning("Retrying in {Seconds}s.", nextAttemptDelay.TotalSeconds.ToString("F1"));
nextAttemptDelay = await WaitForRetry(nextAttemptDelay, cancellationToken); nextAttemptDelay = await WaitForRetry(nextAttemptDelay, cancellationToken);
} }
@@ -136,6 +145,30 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
return null; return null;
} }
private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) {
if (certificate == null || sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable)) {
logger.Error("Could not establish a secure connection, server did not provide a certificate.");
}
else if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)) {
logger.Error("Could not establish a secure connection, server certificate has the wrong name: {Name}", certificate.Subject);
}
else if (!parameters.CertificateThumbprint.Check(certificate)) {
logger.Error("Could not establish a secure connection, server certificate does not match.");
}
else if (TlsSupport.CheckAlgorithm((X509Certificate2) certificate) is {} error) {
logger.Error("Could not establish a secure connection, server certificate rejected because it uses {ActualAlgorithmName} instead of {ExpectedAlgorithmName}.", error.ActualAlgorithmName, error.ExpectedAlgorithmName);
}
else if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None) {
logger.Error("Could not establish a secure connection, server certificate validation failed.");
}
else {
return true;
}
loggedCertificateValidationError = true;
return false;
}
private async Task<ConnectionResult?> AuthenticateAndPerformHandshake(RpcStream stream, CancellationToken cancellationToken) { private async Task<ConnectionResult?> AuthenticateAndPerformHandshake(RpcStream stream, CancellationToken cancellationToken) {
try { try {
loggedCertificateValidationError = false; loggedCertificateValidationError = false;
@@ -165,14 +198,42 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
await stream.WriteAuthToken(parameters.AuthToken, cancellationToken); await stream.WriteAuthToken(parameters.AuthToken, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
if (await stream.ReadByte(cancellationToken) != 1) { var authResult = (RpcAuthResult) await stream.ReadByte(cancellationToken);
logger.Error("Server rejected authorization token."); switch (authResult) {
return null; case RpcAuthResult.Success:
break;
case RpcAuthResult.UnknownClient:
logger.Error("Server rejected unknown client.");
return null;
case RpcAuthResult.InvalidSecret:
logger.Error("Server rejected unauthorized client.");
return null;
default:
logger.Error("Server rejected client authorization with unknown error code: {ErrorCode}", authResult);
return null;
} }
await stream.WriteGuid(sessionId, cancellationToken); await stream.WriteGuid(sessionGuid, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
var sessionRegistrationResult = (RpcSessionRegistrationResult) await stream.ReadByte(cancellationToken);
switch (sessionRegistrationResult) {
case RpcSessionRegistrationResult.Success:
break;
case RpcSessionRegistrationResult.AlreadyClosed:
wasRejectedDueToClosedSession = true;
logger.Fatal("Server rejected client session because it was already closed.");
return null;
default:
logger.Error("Server rejected client session with unknown error code: {ErrorCode}", sessionRegistrationResult);
return null;
}
var pingInterval = await ReadPingInterval(stream, cancellationToken); var pingInterval = await ReadPingInterval(stream, cancellationToken);
if (pingInterval == null) { if (pingInterval == null) {
return null; return null;
@@ -183,12 +244,15 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
await parameters.Handshake.Perform(stream, cancellationToken); await parameters.Handshake.Perform(stream, cancellationToken);
var finalHandshakeResult = (RpcFinalHandshakeResult) await stream.ReadByte(cancellationToken); var finalHandshakeResult = (RpcFinalHandshakeResult) await stream.ReadByte(cancellationToken);
if (finalHandshakeResult == RpcFinalHandshakeResult.Error) { switch (finalHandshakeResult) {
logger.Error("Server rejected client due to unknown error."); case RpcFinalHandshakeResult.NewSession:
return null; case RpcFinalHandshakeResult.ReusedSession:
return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions);
default:
logger.Error("Server rejected client handshake with unknown error code: {ErrorCode}", finalHandshakeResult);
return null;
} }
return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions);
} }
private async Task<TimeSpan?> ReadPingInterval(RpcStream stream, CancellationToken cancellationToken) { private async Task<TimeSpan?> ReadPingInterval(RpcStream stream, CancellationToken cancellationToken) {
@@ -225,30 +289,6 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
return result.TypeMapping; return result.TypeMapping;
} }
private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) {
if (certificate == null || sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable)) {
logger.Error("Could not establish a secure connection, server did not provide a certificate.");
}
else if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)) {
logger.Error("Could not establish a secure connection, server certificate has the wrong name: {Name}", certificate.Subject);
}
else if (!parameters.CertificateThumbprint.Check(certificate)) {
logger.Error("Could not establish a secure connection, server certificate does not match.");
}
else if (TlsSupport.CheckAlgorithm((X509Certificate2) certificate) is {} error) {
logger.Error("Could not establish a secure connection, server certificate rejected because it uses {ActualAlgorithmName} instead of {ExpectedAlgorithmName}.", error.ActualAlgorithmName, error.ExpectedAlgorithmName);
}
else if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None) {
logger.Error("Could not establish a secure connection, server certificate validation failed.");
}
else {
return true;
}
loggedCertificateValidationError = true;
return false;
}
private static async Task DisconnectSocket(Socket socket, RpcStream? stream) { private static async Task DisconnectSocket(Socket socket, RpcStream? stream) {
if (stream != null) { if (stream != null) {
await stream.DisposeAsync(); await stream.DisposeAsync();

View File

@@ -7,8 +7,6 @@ namespace Phantom.Utils.Rpc.Runtime;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class RpcStream : IAsyncDisposable { public sealed class RpcStream : IAsyncDisposable {
private const int GuidBytes = 16;
private readonly SslStream stream; private readonly SslStream stream;
internal RpcStream(SslStream stream) { internal RpcStream(SslStream stream) {
@@ -76,25 +74,19 @@ public sealed class RpcStream : IAsyncDisposable {
} }
public ValueTask WriteGuid(Guid guid, CancellationToken cancellationToken) { public ValueTask WriteGuid(Guid guid, CancellationToken cancellationToken) {
static void Write(Span<byte> span, Guid guid) { return WriteValue(guid, Serialization.GuidBytes, Serialization.WriteGuid, cancellationToken);
if (!guid.TryWriteBytes(span)) {
throw new ArgumentException("Span is not large enough to write a GUID.", nameof(span));
}
}
return WriteValue(guid, size: GuidBytes, Write, cancellationToken);
} }
public ValueTask<Guid> ReadGuid(CancellationToken cancellationToken) { public ValueTask<Guid> ReadGuid(CancellationToken cancellationToken) {
return ReadValue(static span => new Guid(span), size: GuidBytes, cancellationToken); return ReadValue(static span => new Guid(span), Serialization.GuidBytes, cancellationToken);
} }
public ValueTask WriteAuthToken(AuthToken authToken, CancellationToken cancellationToken) { public ValueTask WriteAuthToken(AuthToken authToken, CancellationToken cancellationToken) {
return stream.WriteAsync(authToken.Bytes.AsMemory(), cancellationToken); return WriteValue(authToken, AuthToken.Length, static (span, value) => value.ToBytes(span), cancellationToken);
} }
public ValueTask<AuthToken> ReadAuthToken(CancellationToken cancellationToken) { public ValueTask<AuthToken> ReadAuthToken(CancellationToken cancellationToken) {
return ReadValue(static span => new AuthToken([..span]), AuthToken.Length, cancellationToken); return ReadValue(AuthToken.FromBytes, AuthToken.Length, cancellationToken);
} }
public ValueTask WriteBytes(ReadOnlyMemory<byte> bytes, CancellationToken cancellationToken) { public ValueTask WriteBytes(ReadOnlyMemory<byte> bytes, CancellationToken cancellationToken) {

View File

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

View File

@@ -1,17 +1,11 @@
using Phantom.Utils.Monads; namespace Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Utils.Rpc.Runtime.Server; public interface IRpcServerClientHandshake {
Task Perform(bool isNewSession, RpcStream stream, Guid clientGuid, CancellationToken cancellationToken);
public interface IRpcServerClientHandshake<T> {
Task<Either<T, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken);
}
public static class RpcServerClientHandshake {
public readonly record struct NoValue;
public sealed record NoOp : IRpcServerClientHandshake<NoValue> { sealed record NoOp : IRpcServerClientHandshake {
public Task<Either<NoValue, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken) { public Task Perform(bool isNewSession, RpcStream stream, Guid clientGuid, CancellationToken cancellationToken) {
return Task.FromResult<Either<NoValue, Exception>>(Either.Left(new NoValue())); return Task.CompletedTask;
} }
} }
} }

View File

@@ -2,6 +2,6 @@
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
public interface IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> { public interface IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> {
IMessageReceiver<TClientToServerMessage> Register(RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage> connection, THandshakeResult handshakeResult); IMessageReceiver<TClientToServerMessage> Register(RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage> connection);
} }

View File

@@ -3,20 +3,21 @@ using System.Net.Security;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Monads; using Phantom.Utils.Rpc.Handshake;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog; using Serilog;
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult> { public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage> {
private readonly string loggerName; private readonly string loggerName;
private readonly ILogger logger; private readonly ILogger logger;
private readonly RpcServerConnectionParameters connectionParameters; private readonly RpcServerConnectionParameters connectionParameters;
private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping messageRegistries; private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping messageRegistries;
private readonly IRpcServerClientHandshake<THandshakeResult> clientHandshake; private readonly IRpcServerClientAuthProvider clientAuthProvider;
private readonly IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> clientRegistrar; private readonly IRpcServerClientHandshake clientHandshake;
private readonly IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> clientRegistrar;
private readonly RpcServerClientSessions<TServerToClientMessage> clientSessions; private readonly RpcServerClientSessions<TServerToClientMessage> clientSessions;
private readonly List<Client> clients = []; private readonly List<Client> clients = [];
@@ -25,13 +26,15 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
string loggerName, string loggerName,
RpcServerConnectionParameters connectionParameters, RpcServerConnectionParameters connectionParameters,
MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries, MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries,
IRpcServerClientHandshake<THandshakeResult> clientHandshake, IRpcServerClientAuthProvider clientAuthProvider,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> clientRegistrar IRpcServerClientHandshake clientHandshake,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> clientRegistrar
) { ) {
this.loggerName = loggerName; this.loggerName = loggerName;
this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult>>(loggerName); this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage>>(loggerName);
this.connectionParameters = connectionParameters; this.connectionParameters = connectionParameters;
this.messageRegistries = messageRegistries.CreateMapping(); this.messageRegistries = messageRegistries.CreateMapping();
this.clientAuthProvider = clientAuthProvider;
this.clientHandshake = clientHandshake; this.clientHandshake = clientHandshake;
this.clientRegistrar = clientRegistrar; this.clientRegistrar = clientRegistrar;
this.clientSessions = new RpcServerClientSessions<TServerToClientMessage>(loggerName, connectionParameters, this.messageRegistries.ToClient.Mapping); this.clientSessions = new RpcServerClientSessions<TServerToClientMessage>(loggerName, connectionParameters, this.messageRegistries.ToClient.Mapping);
@@ -53,6 +56,7 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
var serverData = new SharedData( var serverData = new SharedData(
connectionParameters, connectionParameters,
messageRegistries, messageRegistries,
clientAuthProvider,
clientHandshake, clientHandshake,
clientRegistrar, clientRegistrar,
clientSessions clientSessions
@@ -111,8 +115,9 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
private readonly record struct SharedData( private readonly record struct SharedData(
RpcServerConnectionParameters ConnectionParameters, RpcServerConnectionParameters ConnectionParameters,
MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping MessageDefinitions, MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping MessageDefinitions,
IRpcServerClientHandshake<THandshakeResult> ClientHandshake, IRpcServerClientAuthProvider ClientAuthProvider,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> ClientRegistrar, IRpcServerClientHandshake ClientHandshake,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> ClientRegistrar,
RpcServerClientSessions<TServerToClientMessage> ClientSessions RpcServerClientSessions<TServerToClientMessage> ClientSessions
); );
@@ -144,7 +149,7 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
SslServerAuthenticationOptions sslOptions, SslServerAuthenticationOptions sslOptions,
CancellationToken shutdownToken CancellationToken shutdownToken
) { ) {
this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult>, Client>(PhantomLogger.ConcatNames(serverLoggerName, GetAddressDescriptor(socket))); this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage>, Client>(PhantomLogger.ConcatNames(serverLoggerName, GetAddressDescriptor(socket)));
this.sharedData = sharedData; this.sharedData = sharedData;
this.socket = socket; this.socket = socket;
this.sslOptions = sslOptions; this.sslOptions = sslOptions;
@@ -229,16 +234,26 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
} }
try { try {
var suppliedAuthToken = await stream.ReadAuthToken(cancellationToken); var clientAuthToken = await stream.ReadAuthToken(cancellationToken);
if (!sharedData.ConnectionParameters.AuthToken.FixedTimeEquals(suppliedAuthToken)) {
logger.Warning("Rejected client, invalid authorization token."); RpcAuthResult authResult = await CheckAuthorization(clientAuthToken);
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: (byte) authResult, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
if (authResult != RpcAuthResult.Success) {
return null; return null;
} }
else {
await stream.WriteByte(value: 1, cancellationToken); var clientGuid = clientAuthToken.Guid;
await stream.Flush(cancellationToken); var sessionGuid = await stream.ReadGuid(cancellationToken);
var session = await sharedData.ClientSessions.GetOrCreateSession(clientGuid, sessionGuid);
RpcSessionRegistrationResult sessionRegistrationResult = session == null ? RpcSessionRegistrationResult.AlreadyClosed : RpcSessionRegistrationResult.Success;
await stream.WriteByte(value: (byte) sessionRegistrationResult, cancellationToken);
await stream.Flush(cancellationToken);
if (session == null) {
return null;
} }
await stream.WriteUnsignedShort(sharedData.ConnectionParameters.PingIntervalSeconds, cancellationToken); await stream.WriteUnsignedShort(sharedData.ConnectionParameters.PingIntervalSeconds, cancellationToken);
@@ -246,11 +261,9 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
await sharedData.MessageDefinitions.ToServer.Write(stream, cancellationToken); await sharedData.MessageDefinitions.ToServer.Write(stream, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
var sessionId = await stream.ReadGuid(cancellationToken);
var session = sharedData.ClientSessions.GetOrCreateSession(sessionId);
EstablishedConnection? establishedConnection = await FinalizeHandshake(stream, session, cancellationToken);
RpcFinalHandshakeResult finalHandshakeResult; RpcFinalHandshakeResult finalHandshakeResult;
var establishedConnection = await FinalizeHandshake(stream, clientGuid, session, cancellationToken);
if (establishedConnection == null) { if (establishedConnection == null) {
finalHandshakeResult = RpcFinalHandshakeResult.Error; finalHandshakeResult = RpcFinalHandshakeResult.Error;
} }
@@ -274,28 +287,42 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
} }
} }
private async Task<EstablishedConnection?> FinalizeHandshake(RpcStream stream, RpcServerClientSession<TServerToClientMessage> session, CancellationToken cancellationToken) { private async Task<RpcAuthResult> CheckAuthorization(AuthToken clientAuthToken) {
logger.Information("Client connected with session {SessionId}, new logger name: {LoggerName}", session.SessionId, session.LoggerName); var clientGuid = clientAuthToken.Guid;
logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult>, Client>(session.LoggerName);
switch (await sharedData.ClientHandshake.Perform(session.IsNew, stream, cancellationToken)) { var expectedAuthSecret = await sharedData.ClientAuthProvider.GetAuthSecret(clientGuid);
case Left<THandshakeResult, Exception>(var handshakeResult): if (expectedAuthSecret == null) {
try { logger.Warning("Rejected client, unknown client: {ClientGuid}", clientGuid);
var connection = new RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage>(sharedData.ConnectionParameters, sharedData.MessageDefinitions.ToServer.Mapping, session, stream); return RpcAuthResult.UnknownClient;
var messageReceiver = sharedData.ClientRegistrar.Register(connection, handshakeResult); }
else if (!expectedAuthSecret.FixedTimeEquals(clientAuthToken.Secret)) {
return new EstablishedConnection(session, connection, messageReceiver); logger.Warning("Rejected client, invalid authorization secret.");
} catch (Exception e) { return RpcAuthResult.InvalidSecret;
logger.Error(e, "Could not register client."); }
return null; else {
} return RpcAuthResult.Success;
}
}
private async Task<EstablishedConnection?> FinalizeHandshake(RpcStream stream, Guid clientGuid, RpcServerClientSession<TServerToClientMessage> session, CancellationToken cancellationToken) {
logger.Information("Client {ClientGuid} connected with session {SessionGuid}, new logger name: {LoggerName}", clientGuid, session.SessionGuid, session.LoggerName);
logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage>, Client>(session.LoggerName);
try {
await sharedData.ClientHandshake.Perform(session.IsNew, stream, clientGuid, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Could not finish application handshake.");
return null;
}
try {
var connection = new RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage>(sharedData.ConnectionParameters, sharedData.MessageDefinitions.ToServer.Mapping, session, stream);
var messageReceiver = sharedData.ClientRegistrar.Register(connection);
case Right<THandshakeResult, Exception>(var exception): return new EstablishedConnection(session, connection, messageReceiver);
logger.Error(exception, "Could not finish application handshake."); } catch (Exception e) {
return null; logger.Error(e, "Could not register client.");
return null;
default:
return null;
} }
} }

View File

@@ -13,7 +13,8 @@ sealed class RpcServerClientSession<TServerToClientMessage> : IRpcConnectionProv
private readonly RpcServerClientSessions<TServerToClientMessage> sessions; private readonly RpcServerClientSessions<TServerToClientMessage> sessions;
public string LoggerName { get; } public string LoggerName { get; }
public Guid SessionId { get; } public Guid ClientGuid { get; }
public Guid SessionGuid { get; }
public MessageSender<TServerToClientMessage> MessageSender { get; } public MessageSender<TServerToClientMessage> MessageSender { get; }
public RpcFrameSender<TServerToClientMessage> FrameSender { get; } public RpcFrameSender<TServerToClientMessage> FrameSender { get; }
@@ -28,11 +29,13 @@ sealed class RpcServerClientSession<TServerToClientMessage> : IRpcConnectionProv
public CancellationToken CloseCancellationToken => closeCancellationTokenSource.Token; public CancellationToken CloseCancellationToken => closeCancellationTokenSource.Token;
public RpcServerClientSession(string loggerName, RpcServerConnectionParameters connectionParameters, MessageTypeMapping<TServerToClientMessage> messageTypeMapping, RpcServerClientSessions<TServerToClientMessage> sessions, Guid sessionId) { public RpcServerClientSession(string loggerName, RpcServerConnectionParameters connectionParameters, MessageTypeMapping<TServerToClientMessage> messageTypeMapping, RpcServerClientSessions<TServerToClientMessage> sessions, Guid clientGuid, Guid sessionGuid) {
this.logger = PhantomLogger.Create<RpcServerClientSession<TServerToClientMessage>>(loggerName); this.logger = PhantomLogger.Create<RpcServerClientSession<TServerToClientMessage>>(loggerName);
this.LoggerName = loggerName;
this.sessions = sessions; this.sessions = sessions;
this.SessionId = sessionId;
this.LoggerName = loggerName;
this.ClientGuid = clientGuid;
this.SessionGuid = sessionGuid;
this.FrameSender = new RpcFrameSender<TServerToClientMessage>(loggerName, connectionParameters, this, messageTypeMapping, connectionParameters.PingInterval); this.FrameSender = new RpcFrameSender<TServerToClientMessage>(loggerName, connectionParameters, this, messageTypeMapping, connectionParameters.PingInterval);
this.MessageSender = new MessageSender<TServerToClientMessage>(loggerName, connectionParameters, new IRpcFrameSenderProvider<TServerToClientMessage>.Constant(FrameSender)); this.MessageSender = new MessageSender<TServerToClientMessage>(loggerName, connectionParameters, new IRpcFrameSenderProvider<TServerToClientMessage>.Constant(FrameSender));
@@ -105,7 +108,7 @@ sealed class RpcServerClientSession<TServerToClientMessage> : IRpcConnectionProv
} }
try { try {
await MessageSender.Close(); await MessageSender.Close(TimeSpan.FromSeconds(15));
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Caught exception while closing message sender."); logger.Error(e, "Caught exception while closing message sender.");
} }

View File

@@ -1,54 +1,112 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Akka.Util;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
sealed class RpcServerClientSessions<TServerToClientMessage> { sealed class RpcServerClientSessions<TServerToClientMessage>(
private readonly string loggerName; string loggerName,
private readonly RpcServerConnectionParameters connectionParameters; RpcServerConnectionParameters connectionParameters,
private readonly MessageTypeMapping<TServerToClientMessage> messageTypeMapping; MessageTypeMapping<TServerToClientMessage> messageTypeMapping
) {
private readonly ConcurrentDictionary<Guid, SessionHolder> sessionsByClientGuid = new ();
private readonly ConcurrentSet<Guid> closedSessions = [];
private readonly ConcurrentDictionary<Guid, RpcServerClientSession<TServerToClientMessage>> sessionsById = new (); public int Count => sessionsByClientGuid.Count(static kvp => kvp.Value.IsActive);
private readonly Func<Guid, RpcServerClientSession<TServerToClientMessage>> createSessionFunction;
private int nextSessionSequenceId; private int nextSessionSequenceId;
public int Count => sessionsById.Count; public async Task<RpcServerClientSession<TServerToClientMessage>?> GetOrCreateSession(Guid clientGuid, Guid sessionGuid) {
if (closedSessions.Contains(sessionGuid)) {
public RpcServerClientSessions(string loggerName, RpcServerConnectionParameters connectionParameters, MessageTypeMapping<TServerToClientMessage> messageTypeMapping) { return null;
this.loggerName = loggerName; }
this.connectionParameters = connectionParameters;
this.messageTypeMapping = messageTypeMapping; var sessionHolder = sessionsByClientGuid.GetOrAdd(clientGuid, static (clientGuid, sessions) => new SessionHolder(clientGuid, sessions), this);
this.createSessionFunction = CreateSession; return await sessionHolder.GetOrReplaceSession(sessionGuid);
} }
public RpcServerClientSession<TServerToClientMessage> GetOrCreateSession(Guid sessionId) { private RpcServerClientSession<TServerToClientMessage> CreateSession(Guid clientGuid, Guid sessionGuid) {
return sessionsById.GetOrAdd(sessionId, createSessionFunction); return new RpcServerClientSession<TServerToClientMessage>(NextLoggerName(clientGuid), connectionParameters, messageTypeMapping, this, clientGuid, sessionGuid);
} }
private RpcServerClientSession<TServerToClientMessage> CreateSession(Guid sessionId) { private string NextLoggerName(Guid sessionGuid) {
return new RpcServerClientSession<TServerToClientMessage>(NextLoggerName(sessionId), connectionParameters, messageTypeMapping, this, sessionId); string name = PhantomLogger.ShortenGuid(sessionGuid);
}
private string NextLoggerName(Guid sessionId) {
string name = PhantomLogger.ShortenGuid(sessionId);
return PhantomLogger.ConcatNames(loggerName, name + "/" + Interlocked.Increment(ref nextSessionSequenceId)); return PhantomLogger.ConcatNames(loggerName, name + "/" + Interlocked.Increment(ref nextSessionSequenceId));
} }
public void Remove(RpcServerClientSession<TServerToClientMessage> session) { public void Remove(RpcServerClientSession<TServerToClientMessage> session) {
sessionsById.TryRemove(new KeyValuePair<Guid, RpcServerClientSession<TServerToClientMessage>>(session.SessionId, session)); if (sessionsByClientGuid.TryGetValue(session.ClientGuid, out var sessionHolder)) {
closedSessions.TryAdd(session.SessionGuid);
sessionHolder.ForgetSession(session.SessionGuid);
}
} }
public async Task CloseAll() { public async Task CloseAll() {
List<Task> tasks = []; List<Task> tasks = [];
foreach (Guid sessionId in sessionsById.Keys) { foreach (Guid sessionGuid in sessionsByClientGuid.Keys) {
if (sessionsById.Remove(sessionId, out var session)) { if (sessionsByClientGuid.Remove(sessionGuid, out var sessionHolder)) {
tasks.Add(session.Close(closedByClient: false)); tasks.Add(sessionHolder.CloseSession());
} }
} }
await Task.WhenAll(tasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); await Task.WhenAll(tasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
} }
private sealed class SessionHolder(Guid clientGuid, RpcServerClientSessions<TServerToClientMessage> sessions) {
private readonly Lock @lock = new ();
private RpcServerClientSession<TServerToClientMessage>? session;
[SuppressMessage("ReSharper", "InconsistentlySynchronizedField")]
public bool IsActive => Volatile.Read(ref session) != null;
public async Task<RpcServerClientSession<TServerToClientMessage>> GetOrReplaceSession(Guid sessionGuid) {
RpcServerClientSession<TServerToClientMessage>? createdSession;
RpcServerClientSession<TServerToClientMessage>? replacedSession;
lock (@lock) {
if (session != null && session.SessionGuid == sessionGuid) {
return session;
}
else {
replacedSession = session;
}
createdSession = sessions.CreateSession(clientGuid, sessionGuid);
session = createdSession;
}
if (replacedSession != null) {
await CloseSession(replacedSession);
}
return createdSession;
}
public void ForgetSession(Guid sessionGuid) {
lock (@lock) {
if (session != null && session.SessionGuid == sessionGuid) {
session = null;
}
}
}
public async Task CloseSession() {
RpcServerClientSession<TServerToClientMessage>? sessionToClose;
lock (@lock) {
sessionToClose = session;
session = null;
}
if (sessionToClose != null) {
await CloseSession(sessionToClose);
}
}
private static async Task CloseSession(RpcServerClientSession<TServerToClientMessage> session) {
await session.Close(closedByClient: false).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}
} }

View File

@@ -6,7 +6,6 @@ namespace Phantom.Utils.Rpc.Runtime.Server;
public sealed record RpcServerConnectionParameters( public sealed record RpcServerConnectionParameters(
EndPoint EndPoint, EndPoint EndPoint,
RpcServerCertificate Certificate, RpcServerCertificate Certificate,
AuthToken AuthToken,
ushort PingIntervalSeconds, ushort PingIntervalSeconds,
ushort MessageQueueCapacity, ushort MessageQueueCapacity,
ushort FrameQueueCapacity, ushort FrameQueueCapacity,

View File

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

View File

@@ -0,0 +1,11 @@
namespace Phantom.Utils.Rpc;
static class Serialization {
public const int GuidBytes = 16;
public static void WriteGuid(Span<byte> buffer, Guid guid) {
if (!guid.TryWriteBytes(buffer)) {
throw new InvalidOperationException("Span is not large enough to write a GUID.");
}
}
}

View File

@@ -0,0 +1,52 @@
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using NUnit.Framework;
using Phantom.Utils.Collections;
namespace Phantom.Utils.Tests.Collections;
[TestFixture]
[SuppressMessage("Performance", "CA1861")]
public sealed class SpanIndexEnumeratorTests {
private static SearchValues<char> Search => SearchValues.Create(' ', '-');
private static List<int> Indices(string str) {
List<int> indices = [];
foreach (int index in str.AsSpan().IndicesOf(Search)) {
indices.Add(index);
}
return indices;
}
[Test]
public void Empty() {
Assert.That(Indices(""), Is.EquivalentTo(Array.Empty<int>()));
}
[Test]
public void OnlyFirstIndex() {
Assert.That(Indices(" "), Is.EquivalentTo(new[] { 0 }));
}
[Test]
public void OnlyMiddleIndex() {
Assert.That(Indices("ab cd"), Is.EquivalentTo(new[] { 2 }));
}
[Test]
public void OnlyLastIndex() {
Assert.That(Indices("abc "), Is.EquivalentTo(new[] { 3 }));
}
[Test]
public void FirstAndLastIndex() {
Assert.That(Indices(" abc-"), Is.EquivalentTo(new[] { 0, 4 }));
}
[Test]
public void AllIndices() {
Assert.That(Indices("- - -"), Is.EquivalentTo(new[] { 0, 1, 2, 3, 4, 5, 6 }));
}
}

View File

@@ -0,0 +1,37 @@
using System.Buffers;
using System.Collections;
namespace Phantom.Utils.Collections;
public ref struct SpanIndexEnumerator<T>(ReadOnlySpan<T> span, SearchValues<T> searchValues) : IEnumerator<int> where T : IEquatable<T> {
private readonly ReadOnlySpan<T> span = span;
public int Current { get; private set; } = -1;
readonly object IEnumerator.Current => Current;
public readonly SpanIndexEnumerator<T> GetEnumerator() => this;
public bool MoveNext() {
int startIndex = Current + 1;
int relativeIndex = span[startIndex..].IndexOfAny(searchValues);
if (relativeIndex == -1) {
return false;
}
Current = startIndex + relativeIndex;
return true;
}
public void Reset() {
Current = -1;
}
public void Dispose() {}
}
public static class SpanIndexEnumeratorExtensions {
public static SpanIndexEnumerator<T> IndicesOf<T>(this ReadOnlySpan<T> span, SearchValues<T> searchValues) where T : IEquatable<T> {
return new SpanIndexEnumerator<T>(span, searchValues);
}
}

View File

@@ -1,7 +1,7 @@
<div> <div>
<div class="progress-label">@ChildContent</div> <div class="progress-label">@ChildContent</div>
<div class="progress"> <div class="progress">
<div class="progress-bar" role="progressbar" style="width: @(100 * Value / Maximum)%;" aria-valuenow="@Value" aria-valuemin="0" aria-valuemax="@Maximum"></div> <div class="progress-bar" role="progressbar" style="width: @(Maximum <= 0 ? 0 : 100 * Value / Maximum)%;" aria-valuenow="@Value" aria-valuemin="0" aria-valuemax="@Maximum"></div>
</div> </div>
</div> </div>

View File

@@ -1,31 +1,56 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Utils.Events; using Phantom.Utils.Events;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Web.Services.Authentication; using Phantom.Web.Services.Authentication;
using Phantom.Web.Services.Rpc;
namespace Phantom.Web.Services.Agents; namespace Phantom.Web.Services.Agents;
public sealed class AgentManager { using AgentDictionary = ImmutableDictionary<Guid, Agent>;
private readonly SimpleObservableState<ImmutableArray<Agent>> agents = new (PhantomLogger.Create<AgentManager>("Agents"), ImmutableArray<Agent>.Empty);
public sealed class AgentManager(ControllerConnection controllerConnection) {
private readonly SimpleObservableState<AgentDictionary> agents = new (PhantomLogger.Create<AgentManager>("Agents"), AgentDictionary.Empty);
public EventSubscribers<ImmutableArray<Agent>> AgentsChanged => agents.Subs; public EventSubscribers<AgentDictionary> AgentsChanged => agents.Subs;
internal void RefreshAgents(ImmutableArray<Agent> newAgents) { internal void RefreshAgents(ImmutableArray<Agent> newAgents) {
agents.SetTo(newAgents); agents.SetTo(newAgents.ToImmutableDictionary(static agent => agent.AgentGuid));
} }
public ImmutableArray<Agent> GetAll() { public AgentDictionary GetAll() {
return agents.Value; return agents.Value;
} }
public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) { public Agent? GetByGuid(AuthenticatedUser? authenticatedUser, Guid agentGuid) {
if (authenticatedUser == null) { if (authenticatedUser == null) {
return ImmutableDictionary<Guid, Agent>.Empty; return null;
}
var agent = agents.Value.GetValueOrDefault(agentGuid);
return agent != null && authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid) ? agent : null;
}
public AgentDictionary ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) {
if (authenticatedUser == null) {
return AgentDictionary.Empty;
} }
return agents.Value return agents.Value
.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) .Where(kvp => authenticatedUser.Info.HasAccessToAgent(kvp.Key))
.ToImmutableDictionary(static agent => agent.AgentGuid); .ToImmutableDictionary();
}
public async Task<Result<CreateOrUpdateAgentResult, UserActionFailure>> CreateOrUpdateAgent(AuthenticatedUser? authenticatedUser, Guid agentGuid, AgentConfiguration configuration, CancellationToken cancellationToken) {
if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ManageAllAgents)) {
var message = new CreateOrUpdateAgentMessage(authenticatedUser.Token, agentGuid, configuration);
return await controllerConnection.Send<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>(message, cancellationToken);
}
else {
return UserActionFailure.NotAuthorized;
}
} }
} }

View File

@@ -15,14 +15,9 @@ namespace Phantom.Web.Services.Instances;
using InstanceDictionary = ImmutableDictionary<Guid, Instance>; using InstanceDictionary = ImmutableDictionary<Guid, Instance>;
public sealed class InstanceManager { public sealed class InstanceManager(ControllerConnection controllerConnection) {
private readonly ControllerConnection controllerConnection;
private readonly SimpleObservableState<InstanceDictionary> instances = new (PhantomLogger.Create<InstanceManager>("Instances"), InstanceDictionary.Empty); private readonly SimpleObservableState<InstanceDictionary> instances = new (PhantomLogger.Create<InstanceManager>("Instances"), InstanceDictionary.Empty);
public InstanceManager(ControllerConnection controllerConnection) {
this.controllerConnection = controllerConnection;
}
public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs; public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs;
internal void RefreshInstances(ImmutableArray<Instance> newInstances) { internal void RefreshInstances(ImmutableArray<Instance> newInstances) {

View File

@@ -0,0 +1,6 @@
@page "/agents/create"
@using Phantom.Common.Data.Web.Users
@attribute [Authorize(Permission.ManageAllAgentsPolicy)]
<h1>New Agent</h1>
<AgentAddOrEditForm EditedAgent="null" />

View File

@@ -0,0 +1,37 @@
@page "/agents/{AgentGuid:guid}/edit"
@attribute [Authorize(Permission.ManageAllAgentsPolicy)]
@using Phantom.Common.Data.Web.Agent
@using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services.Agents
@inherits PhantomComponent
@inject AgentManager AgentManager
@if (isLoading) {
<h1>Edit Agent</h1>
<p>Loading...</p>
return;
}
@if (Agent == null) {
<h1>Agent Not Found</h1>
<p>Return to <a href="agents">all agents</a>.</p>
return;
}
<h1>Edit Agent: @Agent.Configuration.AgentName</h1>
<AgentAddOrEditForm EditedAgent="Agent" />
@code {
[Parameter]
public Guid AgentGuid { get; init; }
private Agent? Agent { get; set; }
private bool isLoading = true;
protected override async Task OnInitializedAsync() {
Agent = AgentManager.GetByGuid(await GetAuthenticatedUser(), AgentGuid);
isLoading = false;
}
}

View File

@@ -1,13 +1,20 @@
@page "/agents" @page "/agents"
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.Agent @using Phantom.Common.Data.Web.Agent
@using Phantom.Common.Data.Web.Users
@using Phantom.Utils.Collections @using Phantom.Utils.Collections
@using Phantom.Utils.Cryptography
@using Phantom.Web.Services.Agents @using Phantom.Web.Services.Agents
@inherits Phantom.Web.Components.PhantomComponent @using Phantom.Web.Services.Authorization
@inherits PhantomComponent
@inject AgentManager AgentManager @inject AgentManager AgentManager
<h1>Agents</h1> <h1>Agents</h1>
<PermissionView Permission="Permission.ManageAllAgents">
<a href="agents/create" class="btn btn-primary" role="button">New Agent</a>
</PermissionView>
<Table Items="agentTable"> <Table Items="agentTable">
<HeaderRow> <HeaderRow>
<Column Width="50%">Name</Column> <Column Width="50%">Name</Column>
@@ -16,32 +23,51 @@
<Column>Version</Column> <Column>Version</Column>
<Column Class="text-center">Status</Column> <Column Class="text-center">Status</Column>
<Column Class="text-end" MinWidth="200px">Last Ping</Column> <Column Class="text-end" MinWidth="200px">Last Ping</Column>
<Column>Actions</Column>
</HeaderRow> </HeaderRow>
<ItemRow Context="agent"> <ItemRow Context="agent">
@{ @{
var configuration = agent.Configuration; var connectionKey = TokenGenerator.EncodeBytes(agent.ConnectionKey.AsSpan());
var runtimeInfo = agent.RuntimeInfo;
var usedInstances = agent.Stats?.RunningInstanceCount; var usedInstances = agent.Stats?.RunningInstanceCount;
var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes; var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes;
} }
<Cell> <Cell>
<p class="fw-semibold">@configuration.AgentName</p> <p class="fw-semibold">@agent.Configuration.AgentName</p>
<small class="font-monospace text-uppercase">@agent.AgentGuid.ToString()</small> <small class="font-monospace text-uppercase">@agent.AgentGuid.ToString()</small>
</Cell> </Cell>
<Cell class="text-end"> <Cell class="text-end">
<ProgressBar Value="@(usedInstances ?? 0)" Maximum="@configuration.MaxInstances"> <ProgressBar Value="@(usedInstances ?? 0)" Maximum="@(runtimeInfo.MaxInstances ?? 0)">
@(usedInstances?.ToString() ?? "?") / @configuration.MaxInstances.ToString() @if (runtimeInfo.MaxInstances is {} maxInstances) {
<text>@(usedInstances?.ToString() ?? "?") / @maxInstances.ToString()</text>
}
else {
@:N/A
}
</ProgressBar> </ProgressBar>
</Cell> </Cell>
<Cell class="text-end"> <Cell class="text-end">
<ProgressBar Value="@(usedMemory ?? 0)" Maximum="@configuration.MaxMemory.InMegabytes"> <ProgressBar Value="@(usedMemory ?? 0)" Maximum="@(runtimeInfo.MaxMemory?.InMegabytes ?? 0)">
@(usedMemory?.ToString() ?? "?") / @configuration.MaxMemory.InMegabytes.ToString() MB @if (runtimeInfo.MaxMemory is {} maxMemory) {
<text>@(usedMemory?.ToString() ?? "?") / @maxMemory.InMegabytes MB</text>
}
else {
@:N/A
}
</ProgressBar> </ProgressBar>
</Cell> </Cell>
<Cell class="text-condensed"> @if (runtimeInfo.VersionInfo is {} versionInfo) {
Build: <span class="font-monospace">@configuration.BuildVersion</span> <Cell class="text-condensed">
<br> Build: <span class="font-monospace">@versionInfo.BuildVersion</span>
Protocol: <span class="font-monospace">v@(configuration.ProtocolVersion.ToString())</span> <br>
</Cell> Protocol: <span class="font-monospace">v@(versionInfo.ProtocolVersion.ToString())</span>
</Cell>
}
else {
<Cell>
N/A
</Cell>
}
@switch (agent.ConnectionStatus) { @switch (agent.ConnectionStatus) {
case AgentIsOnline: case AgentIsOnline:
<Cell class="fw-semibold text-center text-success">Online</Cell> <Cell class="fw-semibold text-center text-success">Online</Cell>
@@ -64,6 +90,12 @@
<Cell class="fw-semibold text-center">N/A</Cell> <Cell class="fw-semibold text-center">N/A</Cell>
break; break;
} }
<Cell>
<PermissionView Permission="Permission.ManageAllAgents">
<a href="agents/@agent.AgentGuid/edit" type="button" class="btn btn-primary btn-sm">Edit Agent</a>
<button type="button" class="btn btn-danger btn-sm" data-clipboard="@connectionKey" onclick="copyToClipboard(this);">Copy Agent Key</button>
</PermissionView>
</Cell>
</ItemRow> </ItemRow>
<NoItemsRow> <NoItemsRow>
No agents found. No agents found.
@@ -81,7 +113,8 @@
} }
AgentManager.AgentsChanged.Subscribe(this, agents => { AgentManager.AgentsChanged.Subscribe(this, agents => {
var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) var sortedAgents = agents.Values
.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
.OrderBy(static agent => agent.Configuration.AgentName) .OrderBy(static agent => agent.Configuration.AgentName)
.ToImmutableArray(); .ToImmutableArray();

View File

@@ -3,9 +3,11 @@
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.AuditLog @using Phantom.Common.Data.Web.AuditLog
@using Phantom.Common.Data.Web.Users @using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services.Agents
@using Phantom.Web.Services.Instances @using Phantom.Web.Services.Instances
@using Phantom.Web.Services.Users @using Phantom.Web.Services.Users
@inherits PhantomComponent @inherits PhantomComponent
@inject AgentManager AgentManager
@inject AuditLogManager AuditLogManager @inject AuditLogManager AuditLogManager
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager
@inject UserManager UserManager @inject UserManager UserManager
@@ -55,6 +57,7 @@
private string? loadError; private string? loadError;
private ImmutableDictionary<Guid, string>? userNamesByGuid; private ImmutableDictionary<Guid, string>? userNamesByGuid;
private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
@@ -62,6 +65,7 @@
if (result) { if (result) {
logItems = result.Value; logItems = result.Value;
userNamesByGuid = (await UserManager.GetAll(CancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name); userNamesByGuid = (await UserManager.GetAll(CancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name);
agentNamesByGuid = AgentManager.GetAll().Values.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Configuration.AgentName);
instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName); instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
} }
else { else {
@@ -75,8 +79,9 @@
private string? GetSubjectName(AuditLogSubjectType type, string id) { private string? GetSubjectName(AuditLogSubjectType type, string id) {
return type switch { return type switch {
AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.Agent => agentNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
_ => null, _ => null,
}; };
} }

View File

@@ -65,7 +65,7 @@
var result = await EventLogManager.GetMostRecentItems(await GetAuthenticatedUser(), count: 50, CancellationToken); var result = await EventLogManager.GetMostRecentItems(await GetAuthenticatedUser(), count: 50, CancellationToken);
if (result) { if (result) {
logItems = result.Value; logItems = result.Value;
agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Configuration.AgentName); agentNamesByGuid = AgentManager.GetAll().Values.ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Configuration.AgentName);
instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName); instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
} }
else { else {

View File

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

View File

@@ -0,0 +1,73 @@
@using System.ComponentModel.DataAnnotations
@using Phantom.Common.Data.Web.Agent
@using Phantom.Common.Data.Web.Users
@using Phantom.Utils.Result
@using Phantom.Web.Services
@using Phantom.Web.Services.Agents
@inherits PhantomComponent
@inject AgentManager AgentManager
@inject Navigation Navigation
<Form Model="form" OnSubmit="AddOrEditAgent">
<div class="row">
<div class="col-xl-12 mb-3">
<FormTextInput Id="agent-name" Label="Agent Name" @bind-Value="form.AgentName" />
</div>
</div>
<FormButtonSubmit Label="@(EditedAgent == null ? "Create Agent" : "Edit Agent")" class="btn btn-primary" />
<FormSubmitError />
</Form>
@code {
[Parameter, EditorRequired]
public Agent? EditedAgent { get; init; }
private ConfigureAgentFormModel form = null!;
private sealed class ConfigureAgentFormModel : FormModel {
[Required(ErrorMessage = "Agent name is required.")]
[StringLength(100, ErrorMessage = "Agent name must be at most 100 characters.")]
public string AgentName { get; set; } = string.Empty;
}
protected override void OnInitialized() {
form = new ConfigureAgentFormModel();
if (EditedAgent != null) {
var configuration = EditedAgent.Configuration;
form.AgentName = configuration.AgentName;
}
}
private async Task AddOrEditAgent(EditContext context) {
await form.SubmitModel.StartSubmitting();
var agentGuid = EditedAgent?.AgentGuid ?? Guid.NewGuid();
var agentConfiguration = new AgentConfiguration(
form.AgentName
);
var result = await AgentManager.CreateOrUpdateAgent(await GetAuthenticatedUser(), agentGuid, agentConfiguration, CancellationToken);
switch (result.Variant()) {
case Ok<CreateOrUpdateAgentResult>(CreateOrUpdateAgentResult.Success):
await Navigation.NavigateTo("agents");
break;
case Ok<CreateOrUpdateAgentResult>(var createOrUpdateAgentResult):
form.SubmitModel.StopSubmitting(createOrUpdateAgentResult.ToSentence());
break;
case Err<UserActionFailure>(UserActionFailure.NotAuthorized):
form.SubmitModel.StopSubmitting("You do not have permission to create or edit agents.");
break;
default:
form.SubmitModel.StopSubmitting("Unknown error.");
break;
}
}
}

View File

@@ -18,10 +18,10 @@
@using Phantom.Web.Services.Instances @using Phantom.Web.Services.Instances
@using Phantom.Web.Services.Rpc @using Phantom.Web.Services.Rpc
@inherits PhantomComponent @inherits PhantomComponent
@inject Navigation Navigation
@inject ControllerConnection ControllerConnection
@inject AgentManager AgentManager @inject AgentManager AgentManager
@inject ControllerConnection ControllerConnection
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager
@inject Navigation Navigation
<Form Model="form" OnSubmit="AddOrEditInstance"> <Form Model="form" OnSubmit="AddOrEditInstance">
@{ var selectedAgent = form.SelectedAgent; } @{ var selectedAgent = form.SelectedAgent; }
@@ -29,21 +29,27 @@
<div class="col-xl-7 mb-3"> <div class="col-xl-7 mb-3">
@{ @{
static RenderFragment GetAgentOption(Agent agent) { static RenderFragment GetAgentOption(Agent agent) {
var configuration = agent.Configuration; var runtimeInfo = agent.RuntimeInfo;
return return
@<option value="@agent.AgentGuid"> @<option value="@agent.AgentGuid" disabled="@(agent.ConnectionStatus is not AgentIsOnline)">
@configuration.AgentName @agent.Configuration.AgentName
&bullet; @if (agent.ConnectionStatus is not AgentIsOnline) {
@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances") <text> &bullet; </text>
&bullet; <text>Offline</text>
@(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(configuration.MaxMemory.InMegabytes) MB RAM }
else if (runtimeInfo.MaxInstances is not null && runtimeInfo.MaxMemory is not null) {
<text> &bullet; </text>
<text>@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(runtimeInfo.MaxInstances) @(runtimeInfo.MaxInstances == 1 ? "Instance" : "Instances")</text>
<text> &bullet; </text>
<text>@(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(runtimeInfo.MaxMemory.Value.InMegabytes) MB RAM</text>
}
</option>; </option>;
} }
} }
@if (EditedInstance == null) { @if (EditedInstance == null) {
<FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid"> <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
<option value="" selected>Select which agent will run the instance...</option> <option value="" selected>Select which agent will run the instance...</option>
@foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.ConnectionStatus is AgentIsOnline).OrderBy(static agent => agent.Configuration.AgentName)) { @foreach (var agent in allAgentsByGuid.Values.OrderBy(static agent => agent.Configuration.AgentName)) {
@GetAgentOption(agent) @GetAgentOption(agent)
} }
</FormSelectInput> </FormSelectInput>
@@ -101,8 +107,8 @@
</div> </div>
@{ @{
string? allowedServerPorts = selectedAgent?.Configuration.AllowedServerPorts?.ToString(); string? allowedServerPorts = selectedAgent?.RuntimeInfo.AllowedServerPorts?.ToString();
string? allowedRconPorts = selectedAgent?.Configuration.AllowedRconPorts?.ToString(); string? allowedRconPorts = selectedAgent?.RuntimeInfo.AllowedRconPorts?.ToString();
} }
<div class="col-sm-6 col-xl-2 mb-3"> <div class="col-sm-6 col-xl-2 mb-3">
<FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535"> <FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535">
@@ -141,11 +147,11 @@
} }
<FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar"> <FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar">
<LabelFragment> <LabelFragment>
@if (maximumMemoryUnits == 0) { @if (maximumMemoryUnits == 0 || selectedAgent?.RuntimeInfo.MaxMemory is not {} maxMemory) {
<text>RAM</text> <text>RAM</text>
} }
else { else {
<text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.Configuration.MaxMemory.InMegabytes) MB</code></text> <text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(maxMemory.InMegabytes) MB</code></text>
} }
</LabelFragment> </LabelFragment>
</FormNumberInput> </FormNumberInput>
@@ -209,7 +215,7 @@
public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty; public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty;
public ushort MaximumMemoryUnits => SelectedAgent?.Configuration.MaxMemory.RawValue ?? 0; public ushort MaximumMemoryUnits => SelectedAgent?.RuntimeInfo.MaxMemory?.RawValue ?? 0;
public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits); public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits);
private ushort selectedMemoryUnits = 4; private ushort selectedMemoryUnits = 4;
@@ -250,12 +256,12 @@
public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
protected override string FieldName => nameof(ServerPort); protected override string FieldName => nameof(ServerPort);
protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Configuration.AllowedServerPorts?.Contains((ushort) value) == true; protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.RuntimeInfo.AllowedServerPorts?.Contains((ushort) value) == true;
} }
public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
protected override string FieldName => nameof(RconPort); protected override string FieldName => nameof(RconPort);
protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Configuration.AllowedRconPorts?.Contains((ushort) value) == true; protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.RuntimeInfo.AllowedRconPorts?.Contains((ushort) value) == true;
} }
public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> { public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> {

View File

@@ -28,7 +28,7 @@ static class WebKey {
} }
try { try {
Files.RequireMaximumFileSize(webKeyFilePath, maximumBytes: 64); Files.RequireMaximumFileSize(webKeyFilePath, maximumBytes: 128);
return LoadFromBytes(await File.ReadAllBytesAsync(webKeyFilePath)); return LoadFromBytes(await File.ReadAllBytesAsync(webKeyFilePath));
} catch (IOException e) { } catch (IOException e) {
Logger.Fatal("Error loading web key from file: {WebKeyFilePath}", webKeyFilePath); Logger.Fatal("Error loading web key from file: {WebKeyFilePath}", webKeyFilePath);

View File

@@ -53,10 +53,10 @@ static class WebLauncher {
application.UseExceptionHandler("/_Error"); application.UseExceptionHandler("/_Error");
} }
application.UseStaticFiles();
application.UseRouting(); application.UseRouting();
application.UsePhantomServices(); application.UsePhantomServices();
application.MapStaticAssets();
application.MapControllers(); application.MapControllers();
application.MapBlazorHub(); application.MapBlazorHub();
application.MapFallbackToPage("/_Host"); application.MapFallbackToPage("/_Host");

View File

@@ -7,3 +7,38 @@ function showModal(id) {
function closeModal(id) { function closeModal(id) {
bootstrap.Modal.getInstance(document.getElementById(id)).hide(); bootstrap.Modal.getInstance(document.getElementById(id)).hide();
} }
/**
* @param {HTMLButtonElement} button
*/
async function copyToClipboard(button) {
if (button.getAttribute("data-clipboard-copying") !== null) {
return;
}
button.setAttribute("data-clipboard-copying", "");
try {
const toCopy = button.getAttribute("data-clipboard");
const originalText = button.textContent;
const originalMinWidth = button.style.minWidth;
try {
await navigator.clipboard.writeText(toCopy);
} catch (e) {
console.error(e);
alert("Could not copy to clipboard.");
return;
}
button.style.minWidth = button.offsetWidth + "px";
button.textContent = "Copied!";
await new Promise(resolve => setTimeout(resolve, 2000));
button.textContent = originalText;
button.style.minWidth = originalMinWidth;
} finally {
button.removeAttribute("data-clipboard-copying");
}
}