mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2024-11-25 16:42:54 +01:00
Compare commits
2 Commits
55d8c6bcfc
...
340b236282
Author | SHA1 | Date | |
---|---|---|---|
340b236282 | |||
a0721ccc2f |
@ -9,10 +9,10 @@
|
|||||||
<env name="AGENT_NAME" value="Agent 1" />
|
<env name="AGENT_NAME" value="Agent 1" />
|
||||||
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
|
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
|
||||||
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
|
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
|
||||||
|
<env name="CONTROLLER_HOST" value="localhost" />
|
||||||
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
||||||
<env name="MAX_INSTANCES" value="3" />
|
<env name="MAX_INSTANCES" value="3" />
|
||||||
<env name="MAX_MEMORY" value="12G" />
|
<env name="MAX_MEMORY" value="12G" />
|
||||||
<env name="SERVER_HOST" value="localhost" />
|
|
||||||
</envs>
|
</envs>
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
|
@ -9,10 +9,10 @@
|
|||||||
<env name="AGENT_NAME" value="Agent 2" />
|
<env name="AGENT_NAME" value="Agent 2" />
|
||||||
<env name="ALLOWED_RCON_PORTS" value="27002-27006" />
|
<env name="ALLOWED_RCON_PORTS" value="27002-27006" />
|
||||||
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
|
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
|
||||||
|
<env name="CONTROLLER_HOST" value="localhost" />
|
||||||
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
||||||
<env name="MAX_INSTANCES" value="5" />
|
<env name="MAX_INSTANCES" value="5" />
|
||||||
<env name="MAX_MEMORY" value="10G" />
|
<env name="MAX_MEMORY" value="10G" />
|
||||||
<env name="SERVER_HOST" value="localhost" />
|
|
||||||
</envs>
|
</envs>
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
|
@ -9,10 +9,10 @@
|
|||||||
<env name="AGENT_NAME" value="Agent 3" />
|
<env name="AGENT_NAME" value="Agent 3" />
|
||||||
<env name="ALLOWED_RCON_PORTS" value="27007" />
|
<env name="ALLOWED_RCON_PORTS" value="27007" />
|
||||||
<env name="ALLOWED_SERVER_PORTS" value="26007" />
|
<env name="ALLOWED_SERVER_PORTS" value="26007" />
|
||||||
|
<env name="CONTROLLER_HOST" value="localhost" />
|
||||||
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
|
||||||
<env name="MAX_INSTANCES" value="1" />
|
<env name="MAX_INSTANCES" value="1" />
|
||||||
<env name="MAX_MEMORY" value="2560M" />
|
<env name="MAX_MEMORY" value="2560M" />
|
||||||
<env name="SERVER_HOST" value="localhost" />
|
|
||||||
</envs>
|
</envs>
|
||||||
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
<option name="USE_MONO" value="0" />
|
<option name="USE_MONO" value="0" />
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="Server" type="DotNetProject" factoryName=".NET Project">
|
<configuration default="false" name="Controller" type="DotNetProject" factoryName=".NET Project">
|
||||||
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" />
|
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" />
|
||||||
<option name="PROGRAM_PARAMETERS" value="" />
|
<option name="PROGRAM_PARAMETERS" value="" />
|
||||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Server" />
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Controller" />
|
||||||
<option name="PASS_PARENT_ENVS" value="1" />
|
<option name="PASS_PARENT_ENVS" value="1" />
|
||||||
<envs>
|
<envs>
|
||||||
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
||||||
<env name="PG_DATABASE" value="postgres" />
|
<env name="PG_DATABASE" value="postgres" />
|
||||||
<env name="PG_HOST" value="localhost" />
|
<env name="PG_HOST" value="localhost" />
|
||||||
<env name="PG_PASS" value="development" />
|
<env name="PG_PASS" value="development" />
|
||||||
<env name="PG_PORT" value="9402" />
|
<env name="PG_PORT" value="9403" />
|
||||||
<env name="PG_USER" value="postgres" />
|
<env name="PG_USER" value="postgres" />
|
||||||
<env name="RPC_SERVER_HOST" value="localhost" />
|
<env name="RPC_SERVER_HOST" value="localhost" />
|
||||||
<env name="WEB_SERVER_HOST" value="localhost" />
|
<env name="WEB_SERVER_HOST" value="localhost" />
|
24
.run/Web.run.xml
Normal file
24
.run/Web.run.xml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project">
|
||||||
|
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" />
|
||||||
|
<option name="PROGRAM_PARAMETERS" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" />
|
||||||
|
<option name="PASS_PARENT_ENVS" value="1" />
|
||||||
|
<envs>
|
||||||
|
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
|
||||||
|
<env name="WEB_SERVER_HOST" value="localhost" />
|
||||||
|
</envs>
|
||||||
|
<option name="USE_EXTERNAL_CONSOLE" value="0" />
|
||||||
|
<option name="USE_MONO" value="0" />
|
||||||
|
<option name="RUNTIME_ARGUMENTS" value="" />
|
||||||
|
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
|
||||||
|
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
|
||||||
|
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
|
||||||
|
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
|
||||||
|
<option name="PROJECT_KIND" value="DotNetCore" />
|
||||||
|
<option name="PROJECT_TFM" value="net8.0" />
|
||||||
|
<method v="2">
|
||||||
|
<option name="Build" />
|
||||||
|
</method>
|
||||||
|
</configuration>
|
||||||
|
</component>
|
1
.workdir/Controller/.gitignore
vendored
Normal file
1
.workdir/Controller/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
@ -82,10 +82,10 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
|
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
|
||||||
logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, data.Length);
|
logger.Verbose("Received {MessageType} ({Bytes} B) from controller.", type.Name, data.Length);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
logger.Verbose("Received {Bytes} B message from server.", data.Length);
|
logger.Verbose("Received {Bytes} B message from controller.", data.Length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,7 +93,7 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
|
|||||||
var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None);
|
var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||||
var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask);
|
var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask);
|
||||||
if (finishedTask == unregisterTimeoutTask) {
|
if (finishedTask == unregisterTimeoutTask) {
|
||||||
config.RuntimeLogger.Error("Timed out communicating agent shutdown with the server.");
|
config.RuntimeLogger.Error("Timed out communicating agent shutdown with the controller.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -52,9 +52,9 @@ static class AgentKey {
|
|||||||
|
|
||||||
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
|
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
|
||||||
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
|
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
|
||||||
var serverCertificate = NetMQCertificate.FromPublicKey(publicKey);
|
var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey);
|
||||||
|
|
||||||
Logger.Information("Loaded agent key.");
|
Logger.Information("Loaded agent key.");
|
||||||
return (serverCertificate, agentToken);
|
return (controllerCertificate, agentToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,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 (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
|
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
|
||||||
|
|
||||||
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
|
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
|
||||||
if (agentKey == null) {
|
if (agentKey == null) {
|
||||||
@ -43,7 +43,7 @@ try {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (serverCertificate, agentToken) = agentKey.Value;
|
var (controllerCertificate, agentToken) = agentKey.Value;
|
||||||
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
|
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
|
||||||
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks));
|
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks));
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ try {
|
|||||||
await agentServices.Initialize();
|
await agentServices.Initialize();
|
||||||
|
|
||||||
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
|
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
|
||||||
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), serverHost, serverPort, serverCertificate);
|
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), controllerHost, controllerPort, controllerCertificate);
|
||||||
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
|
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
|
||||||
try {
|
try {
|
||||||
await rpcTask.WaitAsync(shutdownCancellationToken);
|
await rpcTask.WaitAsync(shutdownCancellationToken);
|
||||||
|
@ -6,8 +6,8 @@ using Phantom.Utils.Runtime;
|
|||||||
namespace Phantom.Agent;
|
namespace Phantom.Agent;
|
||||||
|
|
||||||
sealed record Variables(
|
sealed record Variables(
|
||||||
string ServerHost,
|
string ControllerHost,
|
||||||
ushort ServerPort,
|
ushort ControllerPort,
|
||||||
string JavaSearchPath,
|
string JavaSearchPath,
|
||||||
string? AgentKeyToken,
|
string? AgentKeyToken,
|
||||||
string? AgentKeyFilePath,
|
string? AgentKeyFilePath,
|
||||||
@ -23,8 +23,8 @@ sealed record Variables(
|
|||||||
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
|
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
|
||||||
|
|
||||||
return new Variables(
|
return new Variables(
|
||||||
EnvironmentVariables.GetString("SERVER_HOST").Require,
|
EnvironmentVariables.GetString("CONTROLLER_HOST").Require,
|
||||||
EnvironmentVariables.GetPortNumber("SERVER_PORT").WithDefault(9401),
|
EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9401),
|
||||||
javaSearchPath,
|
javaSearchPath,
|
||||||
agentKeyToken,
|
agentKeyToken,
|
||||||
agentKeyFilePath,
|
agentKeyFilePath,
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database.Postgres;
|
||||||
|
|
||||||
|
public sealed class ApplicationDbContextFactory : IDatabaseProvider {
|
||||||
|
private readonly PooledDbContextFactory<ApplicationDbContext> factory;
|
||||||
|
|
||||||
|
public ApplicationDbContextFactory(string connectionString) {
|
||||||
|
this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApplicationDbContext Provide() {
|
||||||
|
return factory.CreateDbContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) {
|
||||||
|
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
|
||||||
|
builder.UseNpgsql(connectionString, ConfigureOptions);
|
||||||
|
return builder.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureOptions(NpgsqlDbContextOptionsBuilder options) {
|
||||||
|
options.CommandTimeout(10);
|
||||||
|
options.MigrationsAssembly(typeof(ApplicationDbContextDesignFactory).Assembly.FullName);
|
||||||
|
}
|
||||||
|
}
|
25
Controller/Phantom.Controller.Database/DatabaseMigrator.cs
Normal file
25
Controller/Phantom.Controller.Database/DatabaseMigrator.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Database;
|
||||||
|
|
||||||
|
public static class DatabaseMigrator {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create(nameof(DatabaseMigrator));
|
||||||
|
|
||||||
|
public static async Task Run(IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
Logger.Information("Connecting to database...");
|
||||||
|
|
||||||
|
var retryConnection = new Throttler(TimeSpan.FromSeconds(10));
|
||||||
|
while (!await ctx.Database.CanConnectAsync(cancellationToken)) {
|
||||||
|
Logger.Warning("Cannot connect to database, retrying...");
|
||||||
|
await retryConnection.Wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Information("Running migrations...");
|
||||||
|
await ctx.Database.MigrateAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
@ -1,30 +0,0 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Database;
|
|
||||||
|
|
||||||
public sealed class DatabaseProvider {
|
|
||||||
private readonly IServiceScopeFactory serviceScopeFactory;
|
|
||||||
|
|
||||||
public DatabaseProvider(IServiceScopeFactory serviceScopeFactory) {
|
|
||||||
this.serviceScopeFactory = serviceScopeFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Scope CreateScope() {
|
|
||||||
return new Scope(serviceScopeFactory.CreateScope());
|
|
||||||
}
|
|
||||||
|
|
||||||
public readonly struct Scope : IDisposable {
|
|
||||||
private readonly IServiceScope scope;
|
|
||||||
|
|
||||||
public ApplicationDbContext Ctx { get; }
|
|
||||||
|
|
||||||
internal Scope(IServiceScope scope) {
|
|
||||||
this.scope = scope;
|
|
||||||
this.Ctx = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() {
|
|
||||||
scope.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,5 @@
|
|||||||
|
namespace Phantom.Controller.Database;
|
||||||
|
|
||||||
|
public interface IDatabaseProvider {
|
||||||
|
ApplicationDbContext Provide();
|
||||||
|
}
|
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
|
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -27,19 +27,19 @@ public sealed class AgentManager {
|
|||||||
|
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly CancellationToken cancellationToken;
|
||||||
private readonly AgentAuthToken authToken;
|
private readonly AgentAuthToken authToken;
|
||||||
private readonly DatabaseProvider databaseProvider;
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
|
||||||
public AgentManager(ServiceConfiguration configuration, AgentAuthToken authToken, DatabaseProvider databaseProvider, TaskManager taskManager) {
|
public AgentManager(AgentAuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||||
this.cancellationToken = configuration.CancellationToken;
|
|
||||||
this.authToken = authToken;
|
this.authToken = authToken;
|
||||||
this.databaseProvider = databaseProvider;
|
this.databaseProvider = databaseProvider;
|
||||||
|
this.cancellationToken = cancellationToken;
|
||||||
taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
|
taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
internal async Task Initialize() {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
await foreach (var entity in scope.Ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
||||||
var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
|
var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
|
||||||
if (!agents.ByGuid.AddOrReplaceIf(agent.Guid, agent, static oldAgent => oldAgent.IsOffline)) {
|
if (!agents.ByGuid.AddOrReplaceIf(agent.Guid, agent, static oldAgent => oldAgent.IsOffline)) {
|
||||||
// TODO
|
// TODO
|
||||||
@ -68,8 +68,8 @@ public sealed class AgentManager {
|
|||||||
oldAgent.Connection?.Close();
|
oldAgent.Connection?.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var scope = databaseProvider.CreateScope()) {
|
await using (var ctx = databaseProvider.Provide()) {
|
||||||
var entity = scope.Ctx.AgentUpsert.Fetch(agent.Guid);
|
var entity = ctx.AgentUpsert.Fetch(agent.Guid);
|
||||||
|
|
||||||
entity.Name = agent.Name;
|
entity.Name = agent.Name;
|
||||||
entity.ProtocolVersion = agent.ProtocolVersion;
|
entity.ProtocolVersion = agent.ProtocolVersion;
|
||||||
@ -77,7 +77,7 @@ public sealed class AgentManager {
|
|||||||
entity.MaxInstances = agent.MaxInstances;
|
entity.MaxInstances = agent.MaxInstances;
|
||||||
entity.MaxMemory = agent.MaxMemory;
|
entity.MaxMemory = agent.MaxMemory;
|
||||||
|
|
||||||
await scope.Ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
|
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);
|
||||||
|
@ -9,16 +9,16 @@ using Phantom.Utils.Tasks;
|
|||||||
namespace Phantom.Controller.Services.Audit;
|
namespace Phantom.Controller.Services.Audit;
|
||||||
|
|
||||||
public sealed partial class AuditLog {
|
public sealed partial class AuditLog {
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
private readonly DatabaseProvider databaseProvider;
|
|
||||||
private readonly AuthenticationStateProvider authenticationStateProvider;
|
private readonly AuthenticationStateProvider authenticationStateProvider;
|
||||||
private readonly TaskManager taskManager;
|
private readonly TaskManager taskManager;
|
||||||
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
public AuditLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) {
|
public AuditLog(IDatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||||
this.cancellationToken = serviceConfiguration.CancellationToken;
|
|
||||||
this.databaseProvider = databaseProvider;
|
this.databaseProvider = databaseProvider;
|
||||||
this.authenticationStateProvider = authenticationStateProvider;
|
this.authenticationStateProvider = authenticationStateProvider;
|
||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
|
this.cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Guid?> GetCurrentAuthenticatedUserId() {
|
private async Task<Guid?> GetCurrentAuthenticatedUserId() {
|
||||||
@ -27,9 +27,9 @@ public sealed partial class AuditLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddEntityToDatabase(AuditLogEntity logEntity) {
|
private async Task AddEntityToDatabase(AuditLogEntity logEntity) {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
scope.Ctx.AuditLog.Add(logEntity);
|
ctx.AuditLog.Add(logEntity);
|
||||||
await scope.Ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
||||||
@ -42,13 +42,13 @@ public sealed partial class AuditLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
return await scope.Ctx.AuditLog
|
return await ctx.AuditLog
|
||||||
.Include(static entity => entity.User)
|
.Include(static entity => entity.User)
|
||||||
.AsQueryable()
|
.AsQueryable()
|
||||||
.OrderByDescending(static entity => entity.UtcTime)
|
.OrderByDescending(static entity => entity.UtcTime)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
||||||
.ToArrayAsync(cancellationToken);
|
.ToArrayAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
65
Controller/Phantom.Controller.Services/ControllerServices.cs
Normal file
65
Controller/Phantom.Controller.Services/ControllerServices.cs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
using Phantom.Common.Data.Agent;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Minecraft;
|
||||||
|
using Phantom.Controller.Rpc;
|
||||||
|
using Phantom.Controller.Services.Agents;
|
||||||
|
using Phantom.Controller.Services.Events;
|
||||||
|
using Phantom.Controller.Services.Instances;
|
||||||
|
using Phantom.Controller.Services.Rpc;
|
||||||
|
using Phantom.Controller.Services.Users;
|
||||||
|
using Phantom.Controller.Services.Users.Permissions;
|
||||||
|
using Phantom.Controller.Services.Users.Roles;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services;
|
||||||
|
|
||||||
|
public sealed class ControllerServices {
|
||||||
|
private TaskManager TaskManager { get; }
|
||||||
|
private MinecraftVersions MinecraftVersions { get; }
|
||||||
|
|
||||||
|
private AgentManager AgentManager { get; }
|
||||||
|
private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; }
|
||||||
|
private EventLog EventLog { get; }
|
||||||
|
private InstanceManager InstanceManager { get; }
|
||||||
|
private InstanceLogManager InstanceLogManager { get; }
|
||||||
|
|
||||||
|
private UserManager UserManager { get; }
|
||||||
|
private RoleManager RoleManager { get; }
|
||||||
|
private UserRoleManager UserRoleManager { get; }
|
||||||
|
private PermissionManager PermissionManager { get; }
|
||||||
|
|
||||||
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
|
public ControllerServices(IDatabaseProvider databaseProvider, AgentAuthToken agentAuthToken, CancellationToken shutdownCancellationToken) {
|
||||||
|
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>());
|
||||||
|
this.MinecraftVersions = new MinecraftVersions();
|
||||||
|
|
||||||
|
this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken);
|
||||||
|
this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager();
|
||||||
|
this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken);
|
||||||
|
this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken);
|
||||||
|
this.InstanceLogManager = new InstanceLogManager();
|
||||||
|
|
||||||
|
this.UserManager = new UserManager(databaseProvider);
|
||||||
|
this.RoleManager = new RoleManager(databaseProvider);
|
||||||
|
this.UserRoleManager = new UserRoleManager(databaseProvider);
|
||||||
|
this.PermissionManager = new PermissionManager(databaseProvider);
|
||||||
|
|
||||||
|
this.databaseProvider = databaseProvider;
|
||||||
|
this.cancellationToken = shutdownCancellationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessageToServerListener CreateMessageToServerListener(RpcClientConnection connection) {
|
||||||
|
return new MessageToServerListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLog, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Initialize() {
|
||||||
|
await DatabaseMigrator.Run(databaseProvider, cancellationToken);
|
||||||
|
await PermissionManager.Initialize();
|
||||||
|
await RoleManager.Initialize();
|
||||||
|
await AgentManager.Initialize();
|
||||||
|
await InstanceManager.Initialize();
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,28 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Phantom.Controller.Database;
|
using Phantom.Controller.Database;
|
||||||
using Phantom.Controller.Database.Entities;
|
using Phantom.Controller.Database.Entities;
|
||||||
using Phantom.Controller.Database.Enums;
|
using Phantom.Controller.Database.Enums;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
using Phantom.Utils.Tasks;
|
using Phantom.Utils.Tasks;
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Events;
|
namespace Phantom.Controller.Services.Events;
|
||||||
|
|
||||||
public sealed partial class EventLog {
|
public sealed partial class EventLog {
|
||||||
private readonly CancellationToken cancellationToken;
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
private readonly DatabaseProvider databaseProvider;
|
|
||||||
private readonly TaskManager taskManager;
|
private readonly TaskManager taskManager;
|
||||||
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
public EventLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, TaskManager taskManager) {
|
public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
|
||||||
this.cancellationToken = serviceConfiguration.CancellationToken;
|
|
||||||
this.databaseProvider = databaseProvider;
|
this.databaseProvider = databaseProvider;
|
||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
|
this.cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddEntityToDatabase(EventLogEntity logEntity) {
|
private async Task AddEntityToDatabase(EventLogEntity logEntity) {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
scope.Ctx.EventLog.Add(logEntity);
|
ctx.EventLog.Add(logEntity);
|
||||||
await scope.Ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
private void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
|
||||||
@ -28,13 +30,14 @@ public sealed partial class EventLog {
|
|||||||
taskManager.Run("Store event log item to database", () => AddEntityToDatabase(logEntity));
|
taskManager.Run("Store event log item to database", () => AddEntityToDatabase(logEntity));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<EventLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
|
public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
return await scope.Ctx.EventLog
|
return await ctx.EventLog
|
||||||
.AsQueryable()
|
.AsQueryable()
|
||||||
.OrderByDescending(static entity => entity.UtcTime)
|
.OrderByDescending(static entity => entity.UtcTime)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
.Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
|
||||||
.ToArrayAsync(cancellationToken);
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableArrayAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,23 +24,23 @@ public sealed class InstanceManager {
|
|||||||
|
|
||||||
public EventSubscribers<ImmutableDictionary<Guid, Instance>> InstancesChanged => instances.Subs;
|
public EventSubscribers<ImmutableDictionary<Guid, Instance>> InstancesChanged => instances.Subs;
|
||||||
|
|
||||||
private readonly CancellationToken cancellationToken;
|
|
||||||
private readonly AgentManager agentManager;
|
private readonly AgentManager agentManager;
|
||||||
private readonly MinecraftVersions minecraftVersions;
|
private readonly MinecraftVersions minecraftVersions;
|
||||||
private readonly DatabaseProvider databaseProvider;
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
|
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
|
||||||
|
|
||||||
public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, MinecraftVersions minecraftVersions, DatabaseProvider databaseProvider) {
|
public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
|
||||||
this.cancellationToken = configuration.CancellationToken;
|
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.minecraftVersions = minecraftVersions;
|
this.minecraftVersions = minecraftVersions;
|
||||||
this.databaseProvider = databaseProvider;
|
this.databaseProvider = databaseProvider;
|
||||||
|
this.cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Initialize() {
|
public async Task Initialize() {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
||||||
await foreach (var entity in scope.Ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
|
|
||||||
var configuration = new InstanceConfiguration(
|
var configuration = new InstanceConfiguration(
|
||||||
entity.AgentGuid,
|
entity.AgentGuid,
|
||||||
entity.InstanceGuid,
|
entity.InstanceGuid,
|
||||||
@ -98,8 +98,8 @@ public sealed class InstanceManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.Is(AddOrEditInstanceResult.Success)) {
|
if (result.Is(AddOrEditInstanceResult.Success)) {
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
InstanceEntity entity = scope.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
|
InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
|
||||||
|
|
||||||
entity.AgentGuid = configuration.AgentGuid;
|
entity.AgentGuid = configuration.AgentGuid;
|
||||||
entity.InstanceName = configuration.InstanceName;
|
entity.InstanceName = configuration.InstanceName;
|
||||||
@ -111,7 +111,7 @@ public sealed class InstanceManager {
|
|||||||
entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
|
entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
|
||||||
entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
|
entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
|
||||||
|
|
||||||
await scope.Ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
else if (isNewInstance) {
|
else if (isNewInstance) {
|
||||||
instances.ByGuid.Remove(configuration.InstanceGuid);
|
instances.ByGuid.Remove(configuration.InstanceGuid);
|
||||||
@ -188,11 +188,11 @@ public sealed class InstanceManager {
|
|||||||
try {
|
try {
|
||||||
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
|
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
|
||||||
|
|
||||||
using var scope = databaseProvider.CreateScope();
|
await using var ctx = databaseProvider.Provide();
|
||||||
var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken);
|
var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken);
|
||||||
if (entity != null) {
|
if (entity != null) {
|
||||||
entity.LaunchAutomatically = shouldLaunchAutomatically;
|
entity.LaunchAutomatically = shouldLaunchAutomatically;
|
||||||
await scope.Ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
modifyInstancesSemaphore.Release();
|
modifyInstancesSemaphore.Release();
|
||||||
|
@ -15,25 +15,25 @@ namespace Phantom.Controller.Services.Rpc;
|
|||||||
|
|
||||||
public sealed class MessageToServerListener : IMessageToServerListener {
|
public sealed class MessageToServerListener : IMessageToServerListener {
|
||||||
private readonly RpcClientConnection connection;
|
private readonly RpcClientConnection connection;
|
||||||
private readonly CancellationToken cancellationToken;
|
|
||||||
private readonly AgentManager agentManager;
|
private readonly AgentManager agentManager;
|
||||||
private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
|
private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
|
||||||
private readonly InstanceManager instanceManager;
|
private readonly InstanceManager instanceManager;
|
||||||
private readonly InstanceLogManager instanceLogManager;
|
private readonly InstanceLogManager instanceLogManager;
|
||||||
private readonly EventLog eventLog;
|
private readonly EventLog eventLog;
|
||||||
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>();
|
private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>();
|
||||||
|
|
||||||
public bool IsDisposed { get; private set; }
|
public bool IsDisposed { get; private set; }
|
||||||
|
|
||||||
internal MessageToServerListener(RpcClientConnection connection, ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog) {
|
internal MessageToServerListener(RpcClientConnection connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog, CancellationToken cancellationToken) {
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
this.cancellationToken = configuration.CancellationToken;
|
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentJavaRuntimesManager = agentJavaRuntimesManager;
|
this.agentJavaRuntimesManager = agentJavaRuntimesManager;
|
||||||
this.instanceManager = instanceManager;
|
this.instanceManager = instanceManager;
|
||||||
this.instanceLogManager = instanceLogManager;
|
this.instanceLogManager = instanceLogManager;
|
||||||
this.eventLog = eventLog;
|
this.eventLog = eventLog;
|
||||||
|
this.cancellationToken = cancellationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message) {
|
public async Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message) {
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
using Phantom.Controller.Rpc;
|
|
||||||
using Phantom.Controller.Services.Agents;
|
|
||||||
using Phantom.Controller.Services.Events;
|
|
||||||
using Phantom.Controller.Services.Instances;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Rpc;
|
|
||||||
|
|
||||||
public sealed class MessageToServerListenerFactory {
|
|
||||||
private readonly ServiceConfiguration configuration;
|
|
||||||
private readonly AgentManager agentManager;
|
|
||||||
private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
|
|
||||||
private readonly InstanceManager instanceManager;
|
|
||||||
private readonly InstanceLogManager instanceLogManager;
|
|
||||||
private readonly EventLog eventLog;
|
|
||||||
|
|
||||||
public MessageToServerListenerFactory(ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog) {
|
|
||||||
this.configuration = configuration;
|
|
||||||
this.agentManager = agentManager;
|
|
||||||
this.agentJavaRuntimesManager = agentJavaRuntimesManager;
|
|
||||||
this.instanceManager = instanceManager;
|
|
||||||
this.instanceLogManager = instanceLogManager;
|
|
||||||
this.eventLog = eventLog;
|
|
||||||
}
|
|
||||||
|
|
||||||
public MessageToServerListener CreateListener(RpcClientConnection connection) {
|
|
||||||
return new MessageToServerListener(connection, configuration, agentManager, agentJavaRuntimesManager, instanceManager, instanceLogManager, eventLog);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,6 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using Phantom.Web.Identity.Data;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Authorization;
|
namespace Phantom.Controller.Services.Users.Permissions;
|
||||||
|
|
||||||
public sealed class IdentityPermissions {
|
public sealed class IdentityPermissions {
|
||||||
internal static IdentityPermissions None { get; } = new ();
|
internal static IdentityPermissions None { get; } = new ();
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Web.Identity.Data;
|
namespace Phantom.Controller.Services.Users.Permissions;
|
||||||
|
|
||||||
public sealed record Permission(string Id, Permission? Parent) {
|
public sealed record Permission(string Id, Permission? Parent) {
|
||||||
private static readonly List<Permission> AllPermissions = new ();
|
private static readonly List<Permission> AllPermissions = new ();
|
@ -0,0 +1,68 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services.Users.Permissions;
|
||||||
|
|
||||||
|
public sealed class PermissionManager {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>();
|
||||||
|
|
||||||
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
|
||||||
|
|
||||||
|
public PermissionManager(IDatabaseProvider databaseProvider) {
|
||||||
|
this.databaseProvider = databaseProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task Initialize() {
|
||||||
|
Logger.Information("Adding default permissions to database.");
|
||||||
|
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||||
|
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
|
||||||
|
if (!missingPermissionIds.IsEmpty) {
|
||||||
|
Logger.Information("Adding default permissions: {Permissions}", string.Join(", ", missingPermissionIds));
|
||||||
|
|
||||||
|
foreach (var permissionId in missingPermissionIds) {
|
||||||
|
ctx.Permissions.Add(new PermissionEntity(permissionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
|
||||||
|
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IdentityPermissions FetchPermissionsForUserId(Guid userId) {
|
||||||
|
using var ctx = databaseProvider.Provide();
|
||||||
|
var userPermissions = ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId);
|
||||||
|
var rolePermissions = ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
|
||||||
|
return new IdentityPermissions(userPermissions.Union(rolePermissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) {
|
||||||
|
if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) {
|
||||||
|
return userPermissions;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
|
||||||
|
Guid? userId = UserManager.GetAuthenticatedUserId(user);
|
||||||
|
return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
|
||||||
|
return GetPermissions(user, refreshCache).Check(permission);
|
||||||
|
}
|
||||||
|
}
|
@ -1,60 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Utils.Collections;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users;
|
|
||||||
|
|
||||||
public sealed class RoleManager {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
|
|
||||||
|
|
||||||
private const int MaxRoleNameLength = 40;
|
|
||||||
|
|
||||||
private readonly ApplicationDbContext db;
|
|
||||||
|
|
||||||
public RoleManager(ApplicationDbContext db) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<List<RoleEntity>> GetAll() {
|
|
||||||
return db.Roles.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<ImmutableHashSet<string>> GetAllNames() {
|
|
||||||
return db.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<RoleEntity?> GetByGuid(Guid guid) {
|
|
||||||
return db.Roles.FindAsync(guid);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<RoleEntity, AddRoleError>> Create(Guid guid, string name) {
|
|
||||||
if (string.IsNullOrWhiteSpace(name)) {
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty);
|
|
||||||
}
|
|
||||||
else if (name.Length > MaxRoleNameLength) {
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (await db.Roles.AnyAsync(role => role.Name == name)) {
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists);
|
|
||||||
}
|
|
||||||
|
|
||||||
var role = new RoleEntity(guid, name);
|
|
||||||
|
|
||||||
db.Roles.Add(role);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, guid);
|
|
||||||
return Result.Ok<RoleEntity, AddRoleError>(role);
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.Error(e, "Could not create role \"{Name}\" (GUID {Guid}).", name, guid);
|
|
||||||
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Controller.Services.Users;
|
namespace Phantom.Controller.Services.Users.Roles;
|
||||||
|
|
||||||
public enum AddRoleError : byte {
|
public enum AddRoleError : byte {
|
||||||
NameIsEmpty,
|
NameIsEmpty,
|
@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using Phantom.Controller.Services.Users.Permissions;
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Data;
|
namespace Phantom.Controller.Services.Users.Roles;
|
||||||
|
|
||||||
public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) {
|
public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) {
|
||||||
private static readonly List<Role> AllRoles = new ();
|
private static readonly List<Role> AllRoles = new ();
|
@ -0,0 +1,99 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Controller.Services.Users.Permissions;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services.Users.Roles;
|
||||||
|
|
||||||
|
public sealed class RoleManager {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>();
|
||||||
|
|
||||||
|
private const int MaxRoleNameLength = 40;
|
||||||
|
|
||||||
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
|
||||||
|
public RoleManager(IDatabaseProvider databaseProvider) {
|
||||||
|
this.databaseProvider = databaseProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task Initialize() {
|
||||||
|
Logger.Information("Adding default roles to database.");
|
||||||
|
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
var existingRoleNames = await ctx.Roles
|
||||||
|
.Select(static role => role.Name)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableSetAsync();
|
||||||
|
|
||||||
|
var existingPermissionIdsByRoleGuid = await ctx.RolePermissions
|
||||||
|
.GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId)
|
||||||
|
.ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet());
|
||||||
|
|
||||||
|
foreach (var role in Role.All) {
|
||||||
|
if (!existingRoleNames.Contains(role.Name)) {
|
||||||
|
Logger.Information("Adding default role \"{Name}\".", role.Name);
|
||||||
|
ctx.Roles.Add(new RoleEntity(role.Guid, role.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty;
|
||||||
|
var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
||||||
|
if (!missingPermissionIds.IsEmpty) {
|
||||||
|
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
||||||
|
foreach (var permissionId in missingPermissionIds) {
|
||||||
|
ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<RoleEntity>> GetAll() {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.Roles.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImmutableHashSet<string>> GetAllNames() {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<RoleEntity?> GetByGuid(Guid guid) {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.Roles.FindAsync(guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RoleEntity, AddRoleError>> Create(string name) {
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) {
|
||||||
|
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty);
|
||||||
|
}
|
||||||
|
else if (name.Length > MaxRoleNameLength) {
|
||||||
|
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong);
|
||||||
|
}
|
||||||
|
|
||||||
|
RoleEntity newRole;
|
||||||
|
try {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
if (await ctx.Roles.AnyAsync(role => role.Name == name)) {
|
||||||
|
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
newRole = new RoleEntity(Guid.NewGuid(), name);
|
||||||
|
ctx.Roles.Add(newRole);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Could not create role \"{Name}\".", name);
|
||||||
|
return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, newRole.RoleGuid);
|
||||||
|
return Result.Ok<RoleEntity, AddRoleError>(newRole);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Database;
|
||||||
|
using Phantom.Controller.Database.Entities;
|
||||||
|
using Phantom.Utils.Collections;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace Phantom.Controller.Services.Users.Roles;
|
||||||
|
|
||||||
|
public sealed class UserRoleManager {
|
||||||
|
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
|
||||||
|
|
||||||
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
|
||||||
|
public UserRoleManager(IDatabaseProvider databaseProvider) {
|
||||||
|
this.databaseProvider = databaseProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.UserRoles
|
||||||
|
.Include(static ur => ur.Role)
|
||||||
|
.GroupBy(static ur => ur.UserGuid, static ur => ur.Role)
|
||||||
|
.ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.UserRoles
|
||||||
|
.Include(static ur => ur.Role)
|
||||||
|
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||||
|
.Select(static ur => ur.Role)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.UserRoles
|
||||||
|
.Where(ur => ur.UserGuid == user.UserGuid)
|
||||||
|
.Select(static ur => ur.RoleGuid)
|
||||||
|
.AsAsyncEnumerable()
|
||||||
|
.ToImmutableSetAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Add(UserEntity user, RoleEntity role) {
|
||||||
|
try {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||||
|
if (userRole == null) {
|
||||||
|
userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid);
|
||||||
|
ctx.UserRoles.Add(userRole);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Remove(UserEntity user, RoleEntity role) {
|
||||||
|
try {
|
||||||
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
||||||
|
if (userRole != null) {
|
||||||
|
ctx.UserRoles.Remove(userRole);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
@ -16,10 +16,10 @@ public sealed class UserManager {
|
|||||||
|
|
||||||
private const int MaxUserNameLength = 40;
|
private const int MaxUserNameLength = 40;
|
||||||
|
|
||||||
private readonly ApplicationDbContext db;
|
private readonly IDatabaseProvider databaseProvider;
|
||||||
|
|
||||||
public UserManager(ApplicationDbContext db) {
|
public UserManager(IDatabaseProvider databaseProvider) {
|
||||||
this.db = db;
|
this.databaseProvider = databaseProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
|
public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
|
||||||
@ -35,20 +35,24 @@ public sealed class UserManager {
|
|||||||
return Guid.TryParse(claim.Value, out var guid) ? guid : null;
|
return Guid.TryParse(claim.Value, out var guid) ? guid : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<ImmutableArray<UserEntity>> GetAll() {
|
public async Task<ImmutableArray<UserEntity>> GetAll() {
|
||||||
return db.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
|
public async Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
|
||||||
return db.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<UserEntity?> GetByName(string username) {
|
public async Task<UserEntity?> GetByName(string username) {
|
||||||
return db.Users.FirstOrDefaultAsync(user => user.Name == username);
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
|
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
|
||||||
var user = await db.Users.FirstOrDefaultAsync(user => user.Name == username);
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -57,7 +61,7 @@ public sealed class UserManager {
|
|||||||
case PasswordVerificationResult.SuccessRehashNeeded:
|
case PasswordVerificationResult.SuccessRehashNeeded:
|
||||||
try {
|
try {
|
||||||
UserPasswords.Set(user, password);
|
UserPasswords.Set(user, password);
|
||||||
await db.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name);
|
Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name);
|
||||||
}
|
}
|
||||||
@ -87,58 +91,66 @@ public sealed class UserManager {
|
|||||||
return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations));
|
return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserEntity newUser;
|
||||||
try {
|
try {
|
||||||
if (await db.Users.AnyAsync(user => user.Name == username)) {
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
|
||||||
|
if (await ctx.Users.AnyAsync(user => user.Name == username)) {
|
||||||
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists());
|
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists());
|
||||||
}
|
}
|
||||||
|
|
||||||
var guid = Guid.NewGuid();
|
newUser = new UserEntity(Guid.NewGuid(), username);
|
||||||
var user = new UserEntity(guid, username);
|
UserPasswords.Set(newUser, password);
|
||||||
UserPasswords.Set(user, password);
|
|
||||||
|
|
||||||
db.Users.Add(user);
|
ctx.Users.Add(newUser);
|
||||||
await db.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, guid);
|
|
||||||
return Result.Ok<UserEntity, AddUserError>(user);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Logger.Error(e, "Could not create user \"{Name}\".", username);
|
Logger.Error(e, "Could not create user \"{Name}\".", username);
|
||||||
return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError());
|
return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid);
|
||||||
|
return Result.Ok<UserEntity, AddUserError>(newUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) {
|
public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) {
|
||||||
var user = await db.Users.FindAsync(guid);
|
UserEntity foundUser;
|
||||||
if (user == null) {
|
|
||||||
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
await using (var ctx = databaseProvider.Provide()) {
|
||||||
var requirementViolations = UserPasswords.CheckRequirements(password);
|
var user = await ctx.Users.FindAsync(guid);
|
||||||
if (!requirementViolations.IsEmpty) {
|
if (user == null) {
|
||||||
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
|
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
UserPasswords.Set(user, password);
|
foundUser = user;
|
||||||
await db.SaveChangesAsync();
|
try {
|
||||||
|
var requirementViolations = UserPasswords.CheckRequirements(password);
|
||||||
|
if (!requirementViolations.IsEmpty) {
|
||||||
|
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
|
||||||
|
}
|
||||||
|
|
||||||
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
UserPasswords.Set(user, password);
|
||||||
return Result.Ok<SetUserPasswordError>();
|
await ctx.SaveChangesAsync();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||||
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError());
|
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid);
|
||||||
|
return Result.Ok<SetUserPasswordError>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
|
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
|
||||||
var user = await db.Users.FindAsync(guid);
|
await using var ctx = databaseProvider.Provide();
|
||||||
|
var user = await ctx.Users.FindAsync(guid);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return DeleteUserResult.NotFound;
|
return DeleteUserResult.NotFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.Users.Remove(user);
|
ctx.Users.Remove(user);
|
||||||
await db.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
return DeleteUserResult.Deleted;
|
return DeleteUserResult.Deleted;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
|
||||||
|
@ -1,76 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Utils.Collections;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Controller.Services.Users;
|
|
||||||
|
|
||||||
public sealed class UserRoleManager {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
|
|
||||||
|
|
||||||
private readonly ApplicationDbContext db;
|
|
||||||
|
|
||||||
public UserRoleManager(ApplicationDbContext db) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() {
|
|
||||||
return db.UserRoles
|
|
||||||
.Include(static ur => ur.Role)
|
|
||||||
.GroupBy(static ur => ur.UserGuid, static ur => ur.Role)
|
|
||||||
.ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) {
|
|
||||||
return db.UserRoles
|
|
||||||
.Include(static ur => ur.Role)
|
|
||||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
|
||||||
.Select(static ur => ur.Role)
|
|
||||||
.AsAsyncEnumerable()
|
|
||||||
.ToImmutableArrayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) {
|
|
||||||
return db.UserRoles
|
|
||||||
.Where(ur => ur.UserGuid == user.UserGuid)
|
|
||||||
.Select(static ur => ur.RoleGuid)
|
|
||||||
.AsAsyncEnumerable()
|
|
||||||
.ToImmutableSetAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Add(UserEntity user, RoleEntity role) {
|
|
||||||
try {
|
|
||||||
var userRole = await db.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
|
||||||
if (userRole == null) {
|
|
||||||
userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid);
|
|
||||||
db.UserRoles.Add(userRole);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
|
||||||
return true;
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> Remove(UserEntity user, RoleEntity role) {
|
|
||||||
try {
|
|
||||||
var userRole = await db.UserRoles.FindAsync(user.UserGuid, role.RoleGuid);
|
|
||||||
if (userRole != null) {
|
|
||||||
db.UserRoles.Remove(userRole);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
|
||||||
return true;
|
|
||||||
} catch (Exception e) {
|
|
||||||
Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -18,7 +18,6 @@
|
|||||||
<ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
|
<ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Controller.Rpc\Phantom.Controller.Rpc.csproj" />
|
<ProjectReference Include="..\Phantom.Controller.Rpc\Phantom.Controller.Rpc.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
|
<ProjectReference Include="..\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
|
||||||
<ProjectReference Include="..\..\Web\Phantom.Web\Phantom.Web.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -1,24 +1,19 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Phantom.Common.Logging;
|
using Phantom.Common.Logging;
|
||||||
using Phantom.Controller;
|
using Phantom.Controller;
|
||||||
using Phantom.Controller.Database.Postgres;
|
using Phantom.Controller.Database.Postgres;
|
||||||
using Phantom.Controller.Rpc;
|
using Phantom.Controller.Rpc;
|
||||||
using Phantom.Controller.Services;
|
using Phantom.Controller.Services;
|
||||||
using Phantom.Controller.Services.Rpc;
|
|
||||||
using Phantom.Utils.Cryptography;
|
|
||||||
using Phantom.Utils.IO;
|
using Phantom.Utils.IO;
|
||||||
using Phantom.Utils.Rpc;
|
using Phantom.Utils.Rpc;
|
||||||
using Phantom.Utils.Runtime;
|
using Phantom.Utils.Runtime;
|
||||||
using Phantom.Utils.Tasks;
|
using Phantom.Utils.Tasks;
|
||||||
using WebConfiguration = Phantom.Web.Configuration;
|
|
||||||
using WebLauncher = Phantom.Web.Launcher;
|
|
||||||
|
|
||||||
var cancellationTokenSource = new CancellationTokenSource();
|
var shutdownCancellationTokenSource = new CancellationTokenSource();
|
||||||
|
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
|
||||||
|
|
||||||
PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
|
PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => {
|
||||||
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel server...");
|
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel controller...");
|
||||||
});
|
});
|
||||||
|
|
||||||
static void CreateFolderOrStop(string path, UnixFileMode chmod) {
|
static void CreateFolderOrStop(string path, UnixFileMode chmod) {
|
||||||
@ -35,48 +30,33 @@ static void CreateFolderOrStop(string path, UnixFileMode chmod) {
|
|||||||
try {
|
try {
|
||||||
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
|
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel server...");
|
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel controller...");
|
||||||
PhantomLogger.Root.Information("Server version: {Version}", fullVersion);
|
PhantomLogger.Root.Information("Controller version: {Version}", fullVersion);
|
||||||
|
|
||||||
var (webServerHost, webServerPort, webBasePath, rpcServerHost, rpcServerPort, sqlConnectionString) = Variables.LoadOrStop();
|
var (rpcServerHost, rpcServerPort, sqlConnectionString) = Variables.LoadOrStop();
|
||||||
|
|
||||||
string secretsPath = Path.GetFullPath("./secrets");
|
string secretsPath = Path.GetFullPath("./secrets");
|
||||||
CreateFolderOrStop(secretsPath, Chmod.URWX_GRX);
|
CreateFolderOrStop(secretsPath, Chmod.URWX_GRX);
|
||||||
|
|
||||||
string webKeysPath = Path.GetFullPath("./keys");
|
|
||||||
CreateFolderOrStop(webKeysPath, Chmod.URWX);
|
|
||||||
|
|
||||||
var certificateData = await CertificateFiles.CreateOrLoad(secretsPath);
|
var certificateData = await CertificateFiles.CreateOrLoad(secretsPath);
|
||||||
if (certificateData == null) {
|
if (certificateData == null) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (certificate, agentToken) = certificateData.Value;
|
var (certificate, agentAuthToken) = certificateData.Value;
|
||||||
|
var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
|
||||||
|
var controllerServices = new ControllerServices(dbContextFactory, agentAuthToken, shutdownCancellationToken);
|
||||||
|
|
||||||
PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
|
PhantomLogger.Root.InformationHeading("Launching Phantom Panel server...");
|
||||||
|
|
||||||
var taskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Server"));
|
await controllerServices.Initialize();
|
||||||
|
|
||||||
|
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), rpcServerHost, rpcServerPort, certificate);
|
||||||
|
var rpcTask = RpcLauncher.Launch(rpcConfiguration, controllerServices.CreateMessageToServerListener, shutdownCancellationToken);
|
||||||
try {
|
try {
|
||||||
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), rpcServerHost, rpcServerPort, certificate);
|
await rpcTask.WaitAsync(shutdownCancellationToken);
|
||||||
var webConfiguration = new WebConfiguration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, webKeysPath, cancellationTokenSource.Token);
|
|
||||||
|
|
||||||
var administratorToken = TokenGenerator.Create(60);
|
|
||||||
PhantomLogger.Root.Information("Your administrator token is: {AdministratorToken}", administratorToken);
|
|
||||||
PhantomLogger.Root.Information("For administrator setup, visit: {HttpUrl}{SetupPath}", webConfiguration.HttpUrl, webConfiguration.BasePath + "setup");
|
|
||||||
|
|
||||||
var serviceConfiguration = new ServiceConfiguration(fullVersion, TokenGenerator.GetBytesOrThrow(administratorToken), cancellationTokenSource.Token);
|
|
||||||
var webConfigurator = new WebConfigurator(serviceConfiguration, taskManager, agentToken);
|
|
||||||
var webApplication = await WebLauncher.CreateApplication(webConfiguration, webConfigurator, options => options.UseNpgsql(sqlConnectionString, static options => {
|
|
||||||
options.CommandTimeout(10).MigrationsAssembly(typeof(ApplicationDbContextDesignFactory).Assembly.FullName);
|
|
||||||
}));
|
|
||||||
|
|
||||||
await Task.WhenAll(
|
|
||||||
RpcLauncher.Launch(rpcConfiguration, webApplication.Services.GetRequiredService<MessageToServerListenerFactory>().CreateListener, cancellationTokenSource.Token),
|
|
||||||
WebLauncher.Launch(webConfiguration, webApplication)
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
cancellationTokenSource.Cancel();
|
await rpcTask;
|
||||||
await taskManager.Stop();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@ -88,7 +68,8 @@ try {
|
|||||||
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
|
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
|
||||||
return 1;
|
return 1;
|
||||||
} finally {
|
} finally {
|
||||||
cancellationTokenSource.Dispose();
|
shutdownCancellationTokenSource.Dispose();
|
||||||
|
|
||||||
PhantomLogger.Root.Information("Bye!");
|
PhantomLogger.Root.Information("Bye!");
|
||||||
PhantomLogger.Dispose();
|
PhantomLogger.Dispose();
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,6 @@ using Phantom.Utils.Runtime;
|
|||||||
namespace Phantom.Controller;
|
namespace Phantom.Controller;
|
||||||
|
|
||||||
sealed record Variables(
|
sealed record Variables(
|
||||||
string WebServerHost,
|
|
||||||
ushort WebServerPort,
|
|
||||||
string WebBasePath,
|
|
||||||
string RpcServerHost,
|
string RpcServerHost,
|
||||||
ushort RpcServerPort,
|
ushort RpcServerPort,
|
||||||
string SqlConnectionString
|
string SqlConnectionString
|
||||||
@ -22,9 +19,6 @@ sealed record Variables(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new Variables(
|
return new Variables(
|
||||||
EnvironmentVariables.GetString("WEB_SERVER_HOST").WithDefault("0.0.0.0"),
|
|
||||||
EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").WithDefault(9400),
|
|
||||||
EnvironmentVariables.GetString("WEB_BASE_PATH").Validate(static value => value.StartsWith('/') && value.EndsWith('/'), "Environment variable must begin and end with '/'").WithDefault("/"),
|
|
||||||
EnvironmentVariables.GetString("RPC_SERVER_HOST").WithDefault("0.0.0.0"),
|
EnvironmentVariables.GetString("RPC_SERVER_HOST").WithDefault("0.0.0.0"),
|
||||||
EnvironmentVariables.GetPortNumber("RPC_SERVER_PORT").WithDefault(9401),
|
EnvironmentVariables.GetPortNumber("RPC_SERVER_PORT").WithDefault(9401),
|
||||||
connectionStringBuilder.ToString()
|
connectionStringBuilder.ToString()
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Phantom.Common.Data.Agent;
|
|
||||||
using Phantom.Controller.Minecraft;
|
|
||||||
using Phantom.Controller.Services;
|
|
||||||
using Phantom.Controller.Services.Agents;
|
|
||||||
using Phantom.Controller.Services.Audit;
|
|
||||||
using Phantom.Controller.Services.Events;
|
|
||||||
using Phantom.Controller.Services.Instances;
|
|
||||||
using Phantom.Controller.Services.Rpc;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using WebLauncher = Phantom.Web.Launcher;
|
|
||||||
|
|
||||||
namespace Phantom.Controller;
|
|
||||||
|
|
||||||
sealed class WebConfigurator : WebLauncher.IConfigurator {
|
|
||||||
private readonly ServiceConfiguration serviceConfiguration;
|
|
||||||
private readonly TaskManager taskManager;
|
|
||||||
private readonly AgentAuthToken agentToken;
|
|
||||||
|
|
||||||
public WebConfigurator(ServiceConfiguration serviceConfiguration, TaskManager taskManager, AgentAuthToken agentToken) {
|
|
||||||
this.serviceConfiguration = serviceConfiguration;
|
|
||||||
this.taskManager = taskManager;
|
|
||||||
this.agentToken = agentToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ConfigureServices(IServiceCollection services) {
|
|
||||||
services.AddSingleton(serviceConfiguration);
|
|
||||||
services.AddSingleton(taskManager);
|
|
||||||
services.AddSingleton(agentToken);
|
|
||||||
services.AddSingleton<AgentManager>();
|
|
||||||
services.AddSingleton<AgentJavaRuntimesManager>();
|
|
||||||
services.AddSingleton<EventLog>();
|
|
||||||
services.AddSingleton<InstanceManager>();
|
|
||||||
services.AddSingleton<InstanceLogManager>();
|
|
||||||
services.AddSingleton<MinecraftVersions>();
|
|
||||||
services.AddSingleton<MessageToServerListenerFactory>();
|
|
||||||
|
|
||||||
services.AddScoped<AuditLog>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task LoadFromDatabase(IServiceProvider serviceProvider) {
|
|
||||||
await serviceProvider.GetRequiredService<AgentManager>().Initialize();
|
|
||||||
await serviceProvider.GetRequiredService<InstanceManager>().Initialize();
|
|
||||||
}
|
|
||||||
}
|
|
@ -5,7 +5,7 @@ services:
|
|||||||
image: postgres:14
|
image: postgres:14
|
||||||
container_name: "phantom-panel-postgres"
|
container_name: "phantom-panel-postgres"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:9402:5432"
|
- "127.0.0.1:9403:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
|
65
Dockerfile
65
Dockerfile
@ -7,6 +7,7 @@ ARG TARGETARCH
|
|||||||
ADD . /app
|
ADD . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN mkdir /data && chmod 777 /data
|
||||||
RUN dotnet restore --arch "$TARGETARCH"
|
RUN dotnet restore --arch "$TARGETARCH"
|
||||||
|
|
||||||
|
|
||||||
@ -24,25 +25,31 @@ RUN dotnet publish Agent/Phantom.Agent/Phantom.Agent.csproj \
|
|||||||
--output /app/out
|
--output /app/out
|
||||||
|
|
||||||
|
|
||||||
# +----------------------+
|
# +--------------------------+
|
||||||
# | Build Phantom Server |
|
# | Build Phantom Controller |
|
||||||
# +----------------------+
|
# +--------------------------+
|
||||||
FROM phantom-base-builder AS phantom-server-builder
|
FROM phantom-base-builder AS phantom-controller-builder
|
||||||
|
|
||||||
RUN dotnet publish Web/Phantom.Web/Phantom.Web.csproj \
|
|
||||||
/p:DebugType=None \
|
|
||||||
/p:DebugSymbols=false \
|
|
||||||
--no-restore \
|
|
||||||
--arch "$TARGETARCH" \
|
|
||||||
--configuration Release \
|
|
||||||
--output /app/out
|
|
||||||
|
|
||||||
RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \
|
RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \
|
||||||
/p:DebugType=None \
|
/p:DebugType=None \
|
||||||
/p:DebugSymbols=false \
|
/p:DebugSymbols=false \
|
||||||
--no-restore \
|
--no-restore \
|
||||||
--arch "$TARGETARCH" \
|
--arch "$TARGETARCH" \
|
||||||
--configuration Release \
|
--configuration Release \
|
||||||
|
--output /app/out
|
||||||
|
|
||||||
|
|
||||||
|
# +-------------------+
|
||||||
|
# | Build Phantom Web |
|
||||||
|
# +-------------------+
|
||||||
|
FROM phantom-base-builder AS phantom-controller-builder
|
||||||
|
|
||||||
|
RUN dotnet publish Web/Phantom.Web/Phantom.Web.csproj \
|
||||||
|
/p:DebugType=None \
|
||||||
|
/p:DebugSymbols=false \
|
||||||
|
--no-restore \
|
||||||
|
--arch "$TARGETARCH" \
|
||||||
|
--configuration Release \
|
||||||
--output /app/out
|
--output /app/out
|
||||||
|
|
||||||
|
|
||||||
@ -51,7 +58,6 @@ RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \
|
|||||||
# +------------------------------+
|
# +------------------------------+
|
||||||
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-agent
|
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-agent
|
||||||
|
|
||||||
RUN mkdir /data && chmod 777 /data
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|
||||||
COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8
|
COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8
|
||||||
@ -73,14 +79,25 @@ COPY --from=phantom-agent-builder --chmod=755 /app/out /app
|
|||||||
ENTRYPOINT ["dotnet", "/app/Phantom.Agent.dll"]
|
ENTRYPOINT ["dotnet", "/app/Phantom.Agent.dll"]
|
||||||
|
|
||||||
|
|
||||||
# +-------------------------------+
|
# +-----------------------------------+
|
||||||
# | Finalize Phantom Server image |
|
# | Finalize Phantom Controller image |
|
||||||
# +-------------------------------+
|
# +-----------------------------------+
|
||||||
FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0-preview AS phantom-server
|
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-controller
|
||||||
|
|
||||||
RUN mkdir /data && chmod 777 /data
|
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
|
|
||||||
COPY --from=phantom-server-builder --chmod=755 /app/out /app
|
COPY --from=phantom-controller-builder --chmod=755 /app/out /app
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "/app/Phantom.Controller.dll"]
|
ENTRYPOINT ["dotnet", "/app/Phantom.Controller.dll"]
|
||||||
|
|
||||||
|
|
||||||
|
# +----------------------------+
|
||||||
|
# | Finalize Phantom Web image |
|
||||||
|
# +----------------------------+
|
||||||
|
FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0-preview AS phantom-web
|
||||||
|
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
COPY --from=phantom-web-builder --chmod=755 /app/out /app
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "/app/Phantom.Web.dll"]
|
||||||
|
82
README.md
82
README.md
@ -4,20 +4,21 @@ Phantom Panel is a **work-in-progress** web interface for managing Minecraft ser
|
|||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
Phantom Panel is built on what I'm calling a **Server-Agent architecture**:
|
Phantom Panel has 3 types of services:
|
||||||
|
|
||||||
* The **Server** is provides a web interface, persists data in a database, and sends commands to the **Agents**.
|
* The **Web** provides a web interface for the **Controller**.
|
||||||
* One or more **Agents** receive commands from the **Server**, manage the Minecraft server processes, and report on their status.
|
* The **Controller** manages all state and persists it in a database, and communicates with **Agents**.
|
||||||
|
* One or more **Agents** receive commands from the **Controller**, manage the Minecraft server processes, and report on their status.
|
||||||
|
|
||||||
This architecture has several goals and benefits:
|
This architecture has several goals and benefits:
|
||||||
|
|
||||||
1. The Server and Agents can run on separate computers, in separate containers, or a mixture of both.
|
1. The services can run on separate computers, in separate containers, or a mixture of both.
|
||||||
2. The Server and Agents can be updated independently.
|
2. The services can be updated independently.
|
||||||
- The Server can receive new features, bug fixes, and security updates without the need to shutdown every Minecraft server.
|
- The Controller or Web can receive new features, bug fixes, and security updates without the need to shutdown every Minecraft server.
|
||||||
- Agent updates can be staggered or delayed. For example, if you have Agents in different geographical locations, you could schedule around timezones and update them at times when people are unlikely to be online.
|
- Agent updates can be staggered or delayed. For example, if you have Agents in different geographical locations, you could schedule around timezones and update them at times when people are unlikely to be online.
|
||||||
3. Agents are lightweight processes which should have minimal impact on the performance of Minecraft servers.
|
3. Agents are lightweight processes which should have minimal impact on the performance of Minecraft servers.
|
||||||
|
|
||||||
When an official Server update is released, it will work with older versions of Agents. There is no guarantee it will also work in reverse (updated Agents and an older Server), but if there is an Agent update that is compatible with older Servers, it will be mentioned in the release notes.
|
When an official Controller update is released, it will work with older versions of Agents. There is no guarantee it will also work in reverse (updated Agents and an older Controller), but if there is an Agent update that is compatible with an older Controller, it will be mentioned in the release notes.
|
||||||
|
|
||||||
Note that compatibility is only guaranteed when using official releases. If you build the project from a version of the source between two official releases, you have to understand which changes break compatibility.
|
Note that compatibility is only guaranteed when using official releases. If you build the project from a version of the source between two official releases, you have to understand which changes break compatibility.
|
||||||
|
|
||||||
@ -25,30 +26,30 @@ Note that compatibility is only guaranteed when using official releases. If you
|
|||||||
|
|
||||||
This project is **work-in-progress**, and currently has no official releases. Feel free to try it and experiment, but there will be missing features, bugs, and breaking changes.
|
This project is **work-in-progress**, and currently has no official releases. Feel free to try it and experiment, but there will be missing features, bugs, and breaking changes.
|
||||||
|
|
||||||
For a quick start, I recommend using [Docker](https://www.docker.com/) or another containerization platform. The `Dockerfile` in the root of the repository can build two target images: `phantom-server` and `phantom-agent`.
|
For a quick start, I recommend using [Docker](https://www.docker.com/) or another containerization platform. The `Dockerfile` in the root of the repository can build three target images: `phantom-web`, `phantom-controller`, and `phantom-agent`.
|
||||||
|
|
||||||
Both images put the built application into the `/app` folder. The Agent image also installs Java 8, 16, 17, and 18.
|
All images put the built application into the `/app` folder. The Agent image also installs Java 8, 16, 17, and 18.
|
||||||
|
|
||||||
Files are stored relative to the working directory. In the provided images, the working directory is set to `/data`.
|
Files are stored relative to the working directory. In the provided images, the working directory is set to `/data`.
|
||||||
|
|
||||||
## Server
|
## Controller
|
||||||
|
|
||||||
The Server comprises 3 key areas:
|
The Controller comprises 3 key areas:
|
||||||
|
|
||||||
* **Web server** that provides the web interface.
|
* **Agent RPC server** that Agents connect to.
|
||||||
* **RPC server** that Agents connect to.
|
* **Web RPC server** that Web connects to.
|
||||||
* **Database connection** that requires a PostgreSQL database server in order to persist data.
|
* **PostgreSQL database connection** to persist data.
|
||||||
|
|
||||||
The configuration for these is set via environment variables.
|
The configuration for these is set via environment variables.
|
||||||
|
|
||||||
### Agent Key
|
### Agent & Web Keys
|
||||||
|
|
||||||
When the Server starts for the first time, it will generate and an **Agent Key**. The Agent Key contains an encryption certificate and an authorization token, which are needed for the Agents to connect to the Server.
|
When the Controller starts for the first time, it will generate an **Agent Key** and **Web Key**. These contain encryption certificates and authorization tokens, which are needed for the Agents and Web to connect to the Controller.
|
||||||
|
|
||||||
The Agent Key has two forms:
|
Each key has two forms:
|
||||||
|
|
||||||
* A binary file stored in `/data/secrets/agent.key` that the Agents can read.
|
* A binary file stored in `/data/secrets/agent.key` or `/data/secrets/web.key` that can be distributed to the other services.
|
||||||
* A plaintext-encoded version the Server outputs into the logs on every startup, that can be passed to the Agents in an environemnt variable.
|
* A plaintext-encoded version printed into the logs on every startup, that can be passed to the other services in an environment variable.
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|
||||||
@ -56,14 +57,13 @@ Use volumes to persist the whole `/data` folder.
|
|||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
* **Web Server**
|
* **Agent RPC Server**
|
||||||
- `WEB_SERVER_HOST` is the host. Default: `0.0.0.0`
|
- `AGENT_RPC_SERVER_HOST` is the host. Default: `0.0.0.0`
|
||||||
- `WEB_SERVER_PORT` is the port. Default: `9400`
|
- `AGENT_RPC_SERVER_PORT` is the port. Default: `9401`
|
||||||
- `WEB_BASE_PATH` is the base path of every URL. Must begin with a slash. Default: `/`
|
* **Web RPC Server**
|
||||||
* **RPC Server**
|
- `WEB_RPC_SERVER_HOST` is the host. Default: `0.0.0.0`
|
||||||
- `RPC_SERVER_HOST` is the host. Default: `0.0.0.0`
|
- `WEB_RPC_SERVER_PORT` is the port. Default: `9402`
|
||||||
- `RPC_SERVER_PORT` is the port. Default: `9401`
|
* **PostgreSQL Database Connection**
|
||||||
* **PostgreSQL Database Server**
|
|
||||||
- `PG_HOST` is the hostname.
|
- `PG_HOST` is the hostname.
|
||||||
- `PG_PORT` is the port.
|
- `PG_PORT` is the port.
|
||||||
- `PG_USER` is the username.
|
- `PG_USER` is the username.
|
||||||
@ -81,11 +81,11 @@ The `/data` folder will contain two folders:
|
|||||||
|
|
||||||
Use volumes to persist either the whole `/data` folder, or just `/data/data` if you don't want to persist the volatile files.
|
Use volumes to persist either the whole `/data` folder, or just `/data/data` if you don't want to persist the volatile files.
|
||||||
|
|
||||||
### Environment variables:
|
### Environment variables
|
||||||
|
|
||||||
* **Server Communication**
|
* **Controller Communication**
|
||||||
- `SERVER_HOST` is the hostname of the Server.
|
- `CONTROLLER_HOST` is the hostname of the Controller.
|
||||||
- `SERVER_PORT` is the RPC port of the Server. 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_NAME` is the display name of the Agent. Emoji are allowed.
|
||||||
- `AGENT_KEY` is the plaintext-encoded version of [Agent Key](#agent-key).
|
- `AGENT_KEY` is the plaintext-encoded version of [Agent Key](#agent-key).
|
||||||
- `AGENT_KEY_FILE` is a path to the [Agent Key](#agent-key) binary file.
|
- `AGENT_KEY_FILE` is a path to the [Agent Key](#agent-key) binary file.
|
||||||
@ -98,6 +98,24 @@ Use volumes to persist either the whole `/data` folder, or just `/data/data` if
|
|||||||
- `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000`
|
- `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000`
|
||||||
- `ALLOWED_RCON_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft RCON ports. Example: `25575,25901,36000-37000`
|
- `ALLOWED_RCON_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft RCON ports. Example: `25575,25901,36000-37000`
|
||||||
|
|
||||||
|
## Web
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Use volumes to persist the whole `/data` folder.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
* **Controller Communication**
|
||||||
|
- `CONTROLLER_HOST` is the hostname of the Controller.
|
||||||
|
- `CONTROLLER_PORT` is the Web RPC port of the Controller.
|
||||||
|
- `WEB_KEY` is the plaintext-encoded version of [Web Key](#agent--web-keys).
|
||||||
|
- `WEB_KEY_FILE` is a path to the [Web Key](#agent--web-keys) binary file.
|
||||||
|
* **Web Server**
|
||||||
|
- `WEB_SERVER_HOST` is the host. Default: `0.0.0.0`
|
||||||
|
- `WEB_SERVER_PORT` is the port. Default: `9400`
|
||||||
|
- `WEB_BASE_PATH` is the base path of every URL. Must begin with a slash. Default: `/`
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
Both the Server and Agent support a `LOG_LEVEL` environment variable to set the minimum log level. Possible values:
|
Both the Server and Agent support a `LOG_LEVEL` environment variable to set the minimum log level. Possible values:
|
||||||
@ -116,7 +134,7 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit
|
|||||||
|
|
||||||
1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration:
|
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`
|
||||||
- Port: `9402`
|
- Port: `9403`
|
||||||
- User: `postgres`
|
- User: `postgres`
|
||||||
- Password: `development`
|
- Password: `development`
|
||||||
- Database: `postgres`
|
- Database: `postgres`
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
namespace Phantom.Web.Components.Utils;
|
namespace Phantom.Utils.Tasks;
|
||||||
|
|
||||||
public sealed class Throttler {
|
public sealed class Throttler {
|
||||||
private readonly TimeSpan interval;
|
private readonly TimeSpan interval;
|
@ -1,40 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Services.Users;
|
|
||||||
using Phantom.Web.Identity.Data;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity.Authorization;
|
|
||||||
|
|
||||||
public sealed class PermissionManager {
|
|
||||||
private readonly DatabaseProvider databaseProvider;
|
|
||||||
private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new ();
|
|
||||||
|
|
||||||
public PermissionManager(DatabaseProvider databaseProvider) {
|
|
||||||
this.databaseProvider = databaseProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IdentityPermissions FetchPermissionsForUserId(Guid userId) {
|
|
||||||
using var scope = databaseProvider.CreateScope();
|
|
||||||
var userPermissions = scope.Ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId);
|
|
||||||
var rolePermissions = scope.Ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(scope.Ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
|
|
||||||
return new IdentityPermissions(userPermissions.Union(rolePermissions));
|
|
||||||
}
|
|
||||||
|
|
||||||
private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) {
|
|
||||||
if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) {
|
|
||||||
return userPermissions;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) {
|
|
||||||
Guid? userId = UserManager.GetAuthenticatedUserId(user);
|
|
||||||
return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) {
|
|
||||||
return GetPermissions(user, refreshCache).Check(permission);
|
|
||||||
}
|
|
||||||
}
|
|
@ -20,8 +20,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
|
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
|
||||||
<ProjectReference Include="..\..\Controller\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Controller\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
|
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -1,112 +0,0 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Phantom.Common.Logging;
|
|
||||||
using Phantom.Controller.Database;
|
|
||||||
using Phantom.Controller.Database.Entities;
|
|
||||||
using Phantom.Controller.Services.Users;
|
|
||||||
using Phantom.Utils.Collections;
|
|
||||||
using Phantom.Utils.Runtime;
|
|
||||||
using Phantom.Utils.Tasks;
|
|
||||||
using Phantom.Web.Identity.Data;
|
|
||||||
using ILogger = Serilog.ILogger;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity;
|
|
||||||
|
|
||||||
public sealed class PhantomIdentityConfigurator {
|
|
||||||
private static readonly ILogger Logger = PhantomLogger.Create<PhantomIdentityConfigurator>();
|
|
||||||
|
|
||||||
public static async Task MigrateDatabase(IServiceProvider serviceProvider) {
|
|
||||||
await using var scope = serviceProvider.CreateAsyncScope();
|
|
||||||
await scope.ServiceProvider.GetRequiredService<PhantomIdentityConfigurator>().Initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly ApplicationDbContext db;
|
|
||||||
private readonly RoleManager roleManager;
|
|
||||||
|
|
||||||
public PhantomIdentityConfigurator(ApplicationDbContext db, RoleManager roleManager) {
|
|
||||||
this.db = db;
|
|
||||||
this.roleManager = roleManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Initialize() {
|
|
||||||
await CreatePermissions();
|
|
||||||
await CreateDefaultRoles();
|
|
||||||
await AssignDefaultRolePermissions();
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreatePermissions() {
|
|
||||||
var existingPermissionIds = await db.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync();
|
|
||||||
var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds);
|
|
||||||
|
|
||||||
if (!missingPermissionIds.IsEmpty) {
|
|
||||||
Logger.Information("Adding permissions: {Permissions}", string.Join(", ", missingPermissionIds));
|
|
||||||
foreach (var permissionId in missingPermissionIds) {
|
|
||||||
db.Permissions.Add(new PermissionEntity(permissionId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task CreateDefaultRoles() {
|
|
||||||
Logger.Information("Creating default roles.");
|
|
||||||
|
|
||||||
var allRoleNames = await roleManager.GetAllNames();
|
|
||||||
|
|
||||||
foreach (var (guid, name, _) in Role.All) {
|
|
||||||
if (allRoleNames.Contains(name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await roleManager.Create(guid, name);
|
|
||||||
if (result is Result<RoleEntity, AddRoleError>.Fail fail) {
|
|
||||||
switch (fail.Error) {
|
|
||||||
case AddRoleError.NameIsEmpty:
|
|
||||||
Logger.Fatal("Error creating default role \"{Name}\", name is empty!", name);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
|
|
||||||
case AddRoleError.NameIsTooLong:
|
|
||||||
Logger.Fatal("Error creating default role \"{Name}\", name is too long!", name);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
|
|
||||||
case AddRoleError.NameAlreadyExists:
|
|
||||||
Logger.Warning("Error creating default role \"{Name}\", a role with this name already exists!", name);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
|
|
||||||
default:
|
|
||||||
Logger.Fatal("Error creating default role \"{Name}\", unknown error!", name);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AssignDefaultRolePermissions() {
|
|
||||||
Logger.Information("Assigning default role permissions.");
|
|
||||||
|
|
||||||
foreach (var role in Role.All) {
|
|
||||||
var roleEntity = await roleManager.GetByGuid(role.Guid);
|
|
||||||
if (roleEntity == null) {
|
|
||||||
Logger.Fatal("Error assigning default role permissions, role \"{Name}\" with GUID {Guid} not found.", role.Name, role.Guid);
|
|
||||||
throw StopProcedureException.Instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingPermissionIds = await db.RolePermissions
|
|
||||||
.Where(rp => rp.RoleGuid == roleEntity.RoleGuid)
|
|
||||||
.Select(static rp => rp.PermissionId)
|
|
||||||
.AsAsyncEnumerable()
|
|
||||||
.ToImmutableSetAsync();
|
|
||||||
|
|
||||||
var missingPermissionIds = GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds);
|
|
||||||
if (!missingPermissionIds.IsEmpty) {
|
|
||||||
Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds));
|
|
||||||
foreach (var permissionId in missingPermissionIds) {
|
|
||||||
db.RolePermissions.Add(new RolePermissionEntity(roleEntity.RoleGuid, permissionId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
|
|
||||||
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,10 +2,8 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
using Microsoft.AspNetCore.Components.Server;
|
using Microsoft.AspNetCore.Components.Server;
|
||||||
using Phantom.Controller.Services.Users;
|
|
||||||
using Phantom.Web.Identity.Authentication;
|
using Phantom.Web.Identity.Authentication;
|
||||||
using Phantom.Web.Identity.Authorization;
|
using Phantom.Web.Identity.Authorization;
|
||||||
using Phantom.Web.Identity.Data;
|
|
||||||
|
|
||||||
namespace Phantom.Web.Identity;
|
namespace Phantom.Web.Identity;
|
||||||
|
|
||||||
@ -17,14 +15,8 @@ public static class PhantomIdentityExtensions {
|
|||||||
services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
|
services.AddSingleton(PhantomLoginStore.Create(cancellationToken));
|
||||||
services.AddScoped<PhantomLoginManager>();
|
services.AddScoped<PhantomLoginManager>();
|
||||||
|
|
||||||
services.AddScoped<PhantomIdentityConfigurator>();
|
|
||||||
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
|
services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>();
|
||||||
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
|
services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
|
||||||
|
|
||||||
services.AddScoped<UserManager>();
|
|
||||||
services.AddScoped<RoleManager>();
|
|
||||||
services.AddScoped<UserRoleManager>();
|
|
||||||
services.AddTransient<PermissionManager>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void UsePhantomIdentity(this IApplicationBuilder application) {
|
public static void UsePhantomIdentity(this IApplicationBuilder application) {
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Phantom.Controller.Services;
|
||||||
using Phantom.Controller.Database;
|
using Phantom.Utils.Tasks;
|
||||||
using Phantom.Web.Base;
|
using Phantom.Web.Base;
|
||||||
using Phantom.Web.Components.Utils;
|
|
||||||
using Phantom.Web.Identity;
|
using Phantom.Web.Identity;
|
||||||
using Phantom.Web.Identity.Interfaces;
|
using Phantom.Web.Identity.Interfaces;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@ -10,7 +9,7 @@ using Serilog;
|
|||||||
namespace Phantom.Web;
|
namespace Phantom.Web;
|
||||||
|
|
||||||
public static class Launcher {
|
public static class Launcher {
|
||||||
public static async Task<WebApplication> CreateApplication(Configuration config, IConfigurator configurator, Action<DbContextOptionsBuilder> dbOptionsBuilder) {
|
public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) {
|
||||||
var assembly = typeof(Launcher).Assembly;
|
var assembly = typeof(Launcher).Assembly;
|
||||||
var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
|
var builder = WebApplication.CreateBuilder(new WebApplicationOptions {
|
||||||
ApplicationName = assembly.GetName().Name,
|
ApplicationName = assembly.GetName().Name,
|
||||||
@ -26,32 +25,24 @@ public static class Launcher {
|
|||||||
builder.WebHost.UseStaticWebAssets();
|
builder.WebHost.UseStaticWebAssets();
|
||||||
}
|
}
|
||||||
|
|
||||||
configurator.ConfigureServices(builder.Services);
|
builder.Services.AddSingleton(serviceConfiguration);
|
||||||
|
builder.Services.AddSingleton(taskManager);
|
||||||
|
|
||||||
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
|
builder.Services.AddSingleton<IHostLifetime>(new NullLifetime());
|
||||||
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
|
builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath));
|
||||||
|
|
||||||
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
|
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath));
|
||||||
|
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(dbOptionsBuilder, ServiceLifetime.Transient);
|
|
||||||
builder.Services.AddSingleton<DatabaseProvider>();
|
|
||||||
|
|
||||||
builder.Services.AddPhantomIdentity(config.CancellationToken);
|
builder.Services.AddPhantomIdentity(config.CancellationToken);
|
||||||
builder.Services.AddScoped<ILoginEvents, LoginEvents>();
|
builder.Services.AddScoped<ILoginEvents, LoginEvents>();
|
||||||
|
|
||||||
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
|
builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout");
|
||||||
builder.Services.AddServerSideBlazor();
|
builder.Services.AddServerSideBlazor();
|
||||||
|
|
||||||
var application = builder.Build();
|
return builder.Build();
|
||||||
|
|
||||||
await MigrateDatabase(config, application.Services.GetRequiredService<DatabaseProvider>());
|
|
||||||
await PhantomIdentityConfigurator.MigrateDatabase(application.Services);
|
|
||||||
await configurator.LoadFromDatabase(application.Services);
|
|
||||||
|
|
||||||
return application;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task Launch(Configuration config, WebApplication application) {
|
public static Task Launch(Configuration config, WebApplication application) {
|
||||||
var logger = config.Logger;
|
var logger = config.Logger;
|
||||||
|
|
||||||
application.UseSerilogRequestLogging();
|
application.UseSerilogRequestLogging();
|
||||||
@ -70,7 +61,7 @@ public static class Launcher {
|
|||||||
application.MapFallbackToPage("/_Host");
|
application.MapFallbackToPage("/_Host");
|
||||||
|
|
||||||
logger.Information("Starting Web server on port {Port}...", config.Port);
|
logger.Information("Starting Web server on port {Port}...", config.Port);
|
||||||
await application.RunAsync(config.CancellationToken);
|
return application.RunAsync(config.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NullLifetime : IHostLifetime {
|
private sealed class NullLifetime : IHostLifetime {
|
||||||
@ -82,27 +73,4 @@ public static class Launcher {
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MigrateDatabase(Configuration config, DatabaseProvider databaseProvider) {
|
|
||||||
var logger = config.Logger;
|
|
||||||
|
|
||||||
using var scope = databaseProvider.CreateScope();
|
|
||||||
var database = scope.Ctx.Database;
|
|
||||||
|
|
||||||
logger.Information("Connecting to database...");
|
|
||||||
|
|
||||||
var retryConnection = new Throttler(TimeSpan.FromSeconds(10));
|
|
||||||
while (!await database.CanConnectAsync(config.CancellationToken)) {
|
|
||||||
logger.Warning("Cannot connect to database, retrying...");
|
|
||||||
await retryConnection.Wait();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Information("Running database migrations...");
|
|
||||||
await database.MigrateAsync(); // Do not allow cancellation.
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IConfigurator {
|
|
||||||
void ConfigureServices(IServiceCollection services);
|
|
||||||
Task LoadFromDatabase(IServiceProvider serviceProvider);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@ -19,8 +20,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Controller\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
|
|
||||||
<ProjectReference Include="..\..\Controller\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
|
|
||||||
<ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
|
<ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" />
|
||||||
<ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" />
|
<ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
69
Web/Phantom.Web/Program.cs
Normal file
69
Web/Phantom.Web/Program.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Controller.Services;
|
||||||
|
using Phantom.Utils.Cryptography;
|
||||||
|
using Phantom.Utils.IO;
|
||||||
|
using Phantom.Utils.Runtime;
|
||||||
|
using Phantom.Utils.Tasks;
|
||||||
|
using Phantom.Web;
|
||||||
|
|
||||||
|
var cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
|
||||||
|
PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
|
||||||
|
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel web...");
|
||||||
|
});
|
||||||
|
|
||||||
|
static void CreateFolderOrStop(string path, UnixFileMode chmod) {
|
||||||
|
if (!Directory.Exists(path)) {
|
||||||
|
try {
|
||||||
|
Directories.Create(path, chmod);
|
||||||
|
} catch (Exception e) {
|
||||||
|
PhantomLogger.Root.Fatal(e, "Error creating folder: {FolderName}", path);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
|
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel web...");
|
||||||
|
PhantomLogger.Root.Information("Web version: {Version}", fullVersion);
|
||||||
|
|
||||||
|
var (webServerHost, webServerPort, webBasePath) = Variables.LoadOrStop();
|
||||||
|
|
||||||
|
string webKeysPath = Path.GetFullPath("./keys");
|
||||||
|
CreateFolderOrStop(webKeysPath, Chmod.URWX);
|
||||||
|
|
||||||
|
PhantomLogger.Root.InformationHeading("Launching Phantom Panel web...");
|
||||||
|
|
||||||
|
var taskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Web"));
|
||||||
|
try {
|
||||||
|
var configuration = new Configuration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, webKeysPath, cancellationTokenSource.Token);
|
||||||
|
|
||||||
|
var administratorToken = TokenGenerator.Create(60);
|
||||||
|
PhantomLogger.Root.Information("Your administrator token is: {AdministratorToken}", administratorToken);
|
||||||
|
PhantomLogger.Root.Information("For administrator setup, visit: {HttpUrl}{SetupPath}", configuration.HttpUrl, configuration.BasePath + "setup");
|
||||||
|
|
||||||
|
var serviceConfiguration = new ServiceConfiguration(fullVersion, TokenGenerator.GetBytesOrThrow(administratorToken), cancellationTokenSource.Token);
|
||||||
|
var webApplication = Launcher.CreateApplication(configuration, serviceConfiguration, taskManager);
|
||||||
|
|
||||||
|
await Launcher.Launch(configuration, webApplication);
|
||||||
|
} finally {
|
||||||
|
cancellationTokenSource.Cancel();
|
||||||
|
await taskManager.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (OperationCanceledException) {
|
||||||
|
return 0;
|
||||||
|
} catch (StopProcedureException) {
|
||||||
|
return 1;
|
||||||
|
} catch (Exception e) {
|
||||||
|
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
|
||||||
|
return 1;
|
||||||
|
} finally {
|
||||||
|
cancellationTokenSource.Dispose();
|
||||||
|
PhantomLogger.Root.Information("Bye!");
|
||||||
|
PhantomLogger.Dispose();
|
||||||
|
}
|
27
Web/Phantom.Web/Variables.cs
Normal file
27
Web/Phantom.Web/Variables.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
using Phantom.Common.Logging;
|
||||||
|
using Phantom.Utils.Runtime;
|
||||||
|
|
||||||
|
namespace Phantom.Web;
|
||||||
|
|
||||||
|
sealed record Variables(
|
||||||
|
string WebServerHost,
|
||||||
|
ushort WebServerPort,
|
||||||
|
string WebBasePath
|
||||||
|
) {
|
||||||
|
private static Variables LoadOrThrow() {
|
||||||
|
return new Variables(
|
||||||
|
EnvironmentVariables.GetString("WEB_SERVER_HOST").WithDefault("0.0.0.0"),
|
||||||
|
EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").WithDefault(9400),
|
||||||
|
EnvironmentVariables.GetString("WEB_BASE_PATH").Validate(static value => value.StartsWith('/') && value.EndsWith('/'), "Environment variable must begin and end with '/'").WithDefault("/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Variables LoadOrStop() {
|
||||||
|
try {
|
||||||
|
return LoadOrThrow();
|
||||||
|
} catch (Exception e) {
|
||||||
|
PhantomLogger.Root.Fatal(e.Message);
|
||||||
|
throw StopProcedureException.Instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user