1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2024-11-25 16:42:54 +01:00

Compare commits

...

1 Commits

Author SHA1 Message Date
55d8c6bcfc
Fully separate Controller and Web into their own services 2023-10-10 14:38:34 +02:00
37 changed files with 554 additions and 475 deletions

View File

@ -1,15 +1,15 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Server" type="DotNetProject" factoryName=".NET Project">
<configuration default="false" name="Controller" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Server" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Controller" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="PG_DATABASE" value="postgres" />
<env name="PG_HOST" value="localhost" />
<env name="PG_PASS" value="development" />
<env name="PG_PORT" value="9402" />
<env name="PG_PORT" value="9403" />
<env name="PG_USER" value="postgres" />
<env name="RPC_SERVER_HOST" value="localhost" />
<env name="WEB_SERVER_HOST" value="localhost" />

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

@ -0,0 +1 @@


View File

@ -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);
}
}

View 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);
}
}

View File

@ -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();
}
}
}

View File

@ -0,0 +1,5 @@
namespace Phantom.Controller.Database;
public interface IDatabaseProvider {
ApplicationDbContext Provide();
}

View File

@ -15,6 +15,7 @@
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
</ItemGroup>
</Project>

View File

@ -27,19 +27,19 @@ public sealed class AgentManager {
private readonly CancellationToken cancellationToken;
private readonly AgentAuthToken authToken;
private readonly DatabaseProvider databaseProvider;
private readonly IDatabaseProvider databaseProvider;
public AgentManager(ServiceConfiguration configuration, AgentAuthToken authToken, DatabaseProvider databaseProvider, TaskManager taskManager) {
this.cancellationToken = configuration.CancellationToken;
public AgentManager(AgentAuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
this.authToken = authToken;
this.databaseProvider = databaseProvider;
this.cancellationToken = cancellationToken;
taskManager.Run("Refresh agent status loop", RefreshAgentStatus);
}
public async Task Initialize() {
using var scope = databaseProvider.CreateScope();
internal async Task Initialize() {
await using var ctx = databaseProvider.Provide();
await foreach (var entity in scope.Ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
if (!agents.ByGuid.AddOrReplaceIf(agent.Guid, agent, static oldAgent => oldAgent.IsOffline)) {
// TODO
@ -68,8 +68,8 @@ public sealed class AgentManager {
oldAgent.Connection?.Close();
}
using (var scope = databaseProvider.CreateScope()) {
var entity = scope.Ctx.AgentUpsert.Fetch(agent.Guid);
await using (var ctx = databaseProvider.Provide()) {
var entity = ctx.AgentUpsert.Fetch(agent.Guid);
entity.Name = agent.Name;
entity.ProtocolVersion = agent.ProtocolVersion;
@ -77,7 +77,7 @@ public sealed class AgentManager {
entity.MaxInstances = agent.MaxInstances;
entity.MaxMemory = agent.MaxMemory;
await scope.Ctx.SaveChangesAsync(cancellationToken);
await ctx.SaveChangesAsync(cancellationToken);
}
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid);

View File

@ -9,16 +9,16 @@ using Phantom.Utils.Tasks;
namespace Phantom.Controller.Services.Audit;
public sealed partial class AuditLog {
private readonly CancellationToken cancellationToken;
private readonly DatabaseProvider databaseProvider;
private readonly IDatabaseProvider databaseProvider;
private readonly AuthenticationStateProvider authenticationStateProvider;
private readonly TaskManager taskManager;
private readonly CancellationToken cancellationToken;
public AuditLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) {
this.cancellationToken = serviceConfiguration.CancellationToken;
public AuditLog(IDatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager, CancellationToken cancellationToken) {
this.databaseProvider = databaseProvider;
this.authenticationStateProvider = authenticationStateProvider;
this.taskManager = taskManager;
this.cancellationToken = cancellationToken;
}
private async Task<Guid?> GetCurrentAuthenticatedUserId() {
@ -27,9 +27,9 @@ public sealed partial class AuditLog {
}
private async Task AddEntityToDatabase(AuditLogEntity logEntity) {
using var scope = databaseProvider.CreateScope();
scope.Ctx.AuditLog.Add(logEntity);
await scope.Ctx.SaveChangesAsync(cancellationToken);
await using var ctx = databaseProvider.Provide();
ctx.AuditLog.Add(logEntity);
await ctx.SaveChangesAsync(cancellationToken);
}
private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
@ -42,13 +42,13 @@ public sealed partial class AuditLog {
}
public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
using var scope = databaseProvider.CreateScope();
return await scope.Ctx.AuditLog
.Include(static entity => entity.User)
.AsQueryable()
.OrderByDescending(static entity => entity.UtcTime)
.Take(count)
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
.ToArrayAsync(cancellationToken);
await using var ctx = databaseProvider.Provide();
return await ctx.AuditLog
.Include(static entity => entity.User)
.AsQueryable()
.OrderByDescending(static entity => entity.UtcTime)
.Take(count)
.Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
.ToArrayAsync(cancellationToken);
}
}

View 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();
}
}

View File

@ -1,26 +1,28 @@
using Microsoft.EntityFrameworkCore;
using System.Collections.Immutable;
using Microsoft.EntityFrameworkCore;
using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Enums;
using Phantom.Utils.Collections;
using Phantom.Utils.Tasks;
namespace Phantom.Controller.Services.Events;
public sealed partial class EventLog {
private readonly CancellationToken cancellationToken;
private readonly DatabaseProvider databaseProvider;
private readonly IDatabaseProvider databaseProvider;
private readonly TaskManager taskManager;
private readonly CancellationToken cancellationToken;
public EventLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, TaskManager taskManager) {
this.cancellationToken = serviceConfiguration.CancellationToken;
public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) {
this.databaseProvider = databaseProvider;
this.taskManager = taskManager;
this.cancellationToken = cancellationToken;
}
private async Task AddEntityToDatabase(EventLogEntity logEntity) {
using var scope = databaseProvider.CreateScope();
scope.Ctx.EventLog.Add(logEntity);
await scope.Ctx.SaveChangesAsync(cancellationToken);
await using var ctx = databaseProvider.Provide();
ctx.EventLog.Add(logEntity);
await ctx.SaveChangesAsync(cancellationToken);
}
private void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) {
@ -28,13 +30,14 @@ public sealed partial class EventLog {
taskManager.Run("Store event log item to database", () => AddEntityToDatabase(logEntity));
}
public async Task<EventLogItem[]> GetItems(int count, CancellationToken cancellationToken) {
using var scope = databaseProvider.CreateScope();
return await scope.Ctx.EventLog
.AsQueryable()
.OrderByDescending(static entity => entity.UtcTime)
.Take(count)
.Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
.ToArrayAsync(cancellationToken);
public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) {
await using var ctx = databaseProvider.Provide();
return await ctx.EventLog
.AsQueryable()
.OrderByDescending(static entity => entity.UtcTime)
.Take(count)
.Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data))
.AsAsyncEnumerable()
.ToImmutableArrayAsync(cancellationToken);
}
}

View File

@ -24,23 +24,23 @@ public sealed class InstanceManager {
public EventSubscribers<ImmutableDictionary<Guid, Instance>> InstancesChanged => instances.Subs;
private readonly CancellationToken cancellationToken;
private readonly AgentManager agentManager;
private readonly MinecraftVersions minecraftVersions;
private readonly DatabaseProvider databaseProvider;
private readonly IDatabaseProvider databaseProvider;
private readonly CancellationToken cancellationToken;
private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1);
public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, MinecraftVersions minecraftVersions, DatabaseProvider databaseProvider) {
this.cancellationToken = configuration.CancellationToken;
public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) {
this.agentManager = agentManager;
this.minecraftVersions = minecraftVersions;
this.databaseProvider = databaseProvider;
this.cancellationToken = cancellationToken;
}
public async Task Initialize() {
using var scope = databaseProvider.CreateScope();
await foreach (var entity in scope.Ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
await using var ctx = databaseProvider.Provide();
await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
var configuration = new InstanceConfiguration(
entity.AgentGuid,
entity.InstanceGuid,
@ -98,8 +98,8 @@ public sealed class InstanceManager {
});
if (result.Is(AddOrEditInstanceResult.Success)) {
using var scope = databaseProvider.CreateScope();
InstanceEntity entity = scope.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
await using var ctx = databaseProvider.Provide();
InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid);
entity.AgentGuid = configuration.AgentGuid;
entity.InstanceName = configuration.InstanceName;
@ -111,7 +111,7 @@ public sealed class InstanceManager {
entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid;
entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments);
await scope.Ctx.SaveChangesAsync(cancellationToken);
await ctx.SaveChangesAsync(cancellationToken);
}
else if (isNewInstance) {
instances.ByGuid.Remove(configuration.InstanceGuid);
@ -188,11 +188,11 @@ public sealed class InstanceManager {
try {
instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically });
using var scope = databaseProvider.CreateScope();
var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken);
await using var ctx = databaseProvider.Provide();
var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken);
if (entity != null) {
entity.LaunchAutomatically = shouldLaunchAutomatically;
await scope.Ctx.SaveChangesAsync(cancellationToken);
await ctx.SaveChangesAsync(cancellationToken);
}
} finally {
modifyInstancesSemaphore.Release();

View File

@ -15,25 +15,25 @@ namespace Phantom.Controller.Services.Rpc;
public sealed class MessageToServerListener : IMessageToServerListener {
private readonly RpcClientConnection connection;
private readonly CancellationToken cancellationToken;
private readonly AgentManager agentManager;
private readonly AgentJavaRuntimesManager agentJavaRuntimesManager;
private readonly InstanceManager instanceManager;
private readonly InstanceLogManager instanceLogManager;
private readonly EventLog eventLog;
private readonly CancellationToken cancellationToken;
private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>();
public bool IsDisposed { get; private set; }
internal MessageToServerListener(RpcClientConnection connection, ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog) {
internal MessageToServerListener(RpcClientConnection connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog, CancellationToken cancellationToken) {
this.connection = connection;
this.cancellationToken = configuration.CancellationToken;
this.agentManager = agentManager;
this.agentJavaRuntimesManager = agentJavaRuntimesManager;
this.instanceManager = instanceManager;
this.instanceLogManager = instanceLogManager;
this.eventLog = eventLog;
this.cancellationToken = cancellationToken;
}
public async Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message) {

View File

@ -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);
}
}

View File

@ -1,7 +1,6 @@
using System.Collections.Immutable;
using Phantom.Web.Identity.Data;
namespace Phantom.Web.Identity.Authorization;
namespace Phantom.Controller.Services.Users.Permissions;
public sealed class IdentityPermissions {
internal static IdentityPermissions None { get; } = new ();

View File

@ -1,4 +1,4 @@
namespace Phantom.Web.Identity.Data;
namespace Phantom.Controller.Services.Users.Permissions;
public sealed record Permission(string Id, Permission? Parent) {
private static readonly List<Permission> AllPermissions = new ();

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -1,4 +1,4 @@
namespace Phantom.Controller.Services.Users;
namespace Phantom.Controller.Services.Users.Roles;
public enum AddRoleError : byte {
NameIsEmpty,

View File

@ -1,6 +1,7 @@
using System.Collections.Immutable;
using Phantom.Controller.Services.Users.Permissions;
namespace Phantom.Web.Identity.Data;
namespace Phantom.Controller.Services.Users.Roles;
public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) {
private static readonly List<Role> AllRoles = new ();

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -16,10 +16,10 @@ public sealed class UserManager {
private const int MaxUserNameLength = 40;
private readonly ApplicationDbContext db;
private readonly IDatabaseProvider databaseProvider;
public UserManager(ApplicationDbContext db) {
this.db = db;
public UserManager(IDatabaseProvider databaseProvider) {
this.databaseProvider = databaseProvider;
}
public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) {
@ -35,20 +35,24 @@ public sealed class UserManager {
return Guid.TryParse(claim.Value, out var guid) ? guid : null;
}
public Task<ImmutableArray<UserEntity>> GetAll() {
return db.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
public async Task<ImmutableArray<UserEntity>> GetAll() {
await using var ctx = databaseProvider.Provide();
return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync();
}
public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
return db.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
public async Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) {
await using var ctx = databaseProvider.Provide();
return await ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken);
}
public Task<UserEntity?> GetByName(string username) {
return db.Users.FirstOrDefaultAsync(user => user.Name == username);
public async Task<UserEntity?> GetByName(string username) {
await using var ctx = databaseProvider.Provide();
return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
}
public async Task<UserEntity?> GetAuthenticated(string username, string password) {
var user = await db.Users.FirstOrDefaultAsync(user => user.Name == username);
await using var ctx = databaseProvider.Provide();
var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username);
if (user == null) {
return null;
}
@ -57,7 +61,7 @@ public sealed class UserManager {
case PasswordVerificationResult.SuccessRehashNeeded:
try {
UserPasswords.Set(user, password);
await db.SaveChangesAsync();
await ctx.SaveChangesAsync();
} catch (Exception e) {
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));
}
UserEntity newUser;
try {
if (await db.Users.AnyAsync(user => user.Name == username)) {
await using var ctx = databaseProvider.Provide();
if (await ctx.Users.AnyAsync(user => user.Name == username)) {
return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists());
}
var guid = Guid.NewGuid();
var user = new UserEntity(guid, username);
UserPasswords.Set(user, password);
newUser = new UserEntity(Guid.NewGuid(), username);
UserPasswords.Set(newUser, password);
db.Users.Add(user);
await db.SaveChangesAsync();
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, guid);
return Result.Ok<UserEntity, AddUserError>(user);
ctx.Users.Add(newUser);
await ctx.SaveChangesAsync();
} catch (Exception e) {
Logger.Error(e, "Could not create user \"{Name}\".", username);
return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError());
}
Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid);
return Result.Ok<UserEntity, AddUserError>(newUser);
}
public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) {
var user = await db.Users.FindAsync(guid);
if (user == null) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
}
try {
var requirementViolations = UserPasswords.CheckRequirements(password);
if (!requirementViolations.IsEmpty) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
UserEntity foundUser;
await using (var ctx = databaseProvider.Provide()) {
var user = await ctx.Users.FindAsync(guid);
if (user == null) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound());
}
UserPasswords.Set(user, password);
await db.SaveChangesAsync();
foundUser = user;
try {
var requirementViolations = UserPasswords.CheckRequirements(password);
if (!requirementViolations.IsEmpty) {
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations));
}
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
return Result.Ok<SetUserPasswordError>();
} catch (Exception e) {
Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError());
UserPasswords.Set(user, password);
await ctx.SaveChangesAsync();
} catch (Exception e) {
Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);
return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError());
}
}
Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid);
return Result.Ok<SetUserPasswordError>();
}
public async Task<DeleteUserResult> DeleteByGuid(Guid guid) {
var user = await db.Users.FindAsync(guid);
await using var ctx = databaseProvider.Provide();
var user = await ctx.Users.FindAsync(guid);
if (user == null) {
return DeleteUserResult.NotFound;
}
try {
db.Users.Remove(user);
await db.SaveChangesAsync();
ctx.Users.Remove(user);
await ctx.SaveChangesAsync();
return DeleteUserResult.Deleted;
} catch (Exception e) {
Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid);

View File

@ -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;
}
}
}

View File

@ -18,7 +18,6 @@
<ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Controller.Rpc\Phantom.Controller.Rpc.csproj" />
<ProjectReference Include="..\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
<ProjectReference Include="..\..\Web\Phantom.Web\Phantom.Web.csproj" />
</ItemGroup>
</Project>

View File

@ -1,24 +1,19 @@
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Phantom.Common.Logging;
using Phantom.Controller;
using Phantom.Controller.Database.Postgres;
using Phantom.Controller.Rpc;
using Phantom.Controller.Services;
using Phantom.Controller.Services.Rpc;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Rpc;
using Phantom.Utils.Runtime;
using Phantom.Utils.Tasks;
using WebConfiguration = Phantom.Web.Configuration;
using WebLauncher = Phantom.Web.Launcher;
var cancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel server...");
PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => {
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel controller...");
});
static void CreateFolderOrStop(string path, UnixFileMode chmod) {
@ -35,48 +30,33 @@ static void CreateFolderOrStop(string path, UnixFileMode chmod) {
try {
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel server...");
PhantomLogger.Root.Information("Server version: {Version}", fullVersion);
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel controller...");
PhantomLogger.Root.Information("Controller version: {Version}", fullVersion);
var (webServerHost, webServerPort, webBasePath, rpcServerHost, rpcServerPort, sqlConnectionString) = Variables.LoadOrStop();
var (rpcServerHost, rpcServerPort, sqlConnectionString) = Variables.LoadOrStop();
string secretsPath = Path.GetFullPath("./secrets");
CreateFolderOrStop(secretsPath, Chmod.URWX_GRX);
string webKeysPath = Path.GetFullPath("./keys");
CreateFolderOrStop(webKeysPath, Chmod.URWX);
var certificateData = await CertificateFiles.CreateOrLoad(secretsPath);
if (certificateData == null) {
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...");
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 {
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), rpcServerHost, rpcServerPort, certificate);
var webConfiguration = new WebConfiguration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, webKeysPath, cancellationTokenSource.Token);
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)
);
await rpcTask.WaitAsync(shutdownCancellationToken);
} finally {
cancellationTokenSource.Cancel();
await taskManager.Stop();
await rpcTask;
}
return 0;
@ -88,7 +68,8 @@ try {
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
return 1;
} finally {
cancellationTokenSource.Dispose();
shutdownCancellationTokenSource.Dispose();
PhantomLogger.Root.Information("Bye!");
PhantomLogger.Dispose();
}

View File

@ -5,9 +5,6 @@ using Phantom.Utils.Runtime;
namespace Phantom.Controller;
sealed record Variables(
string WebServerHost,
ushort WebServerPort,
string WebBasePath,
string RpcServerHost,
ushort RpcServerPort,
string SqlConnectionString
@ -22,9 +19,6 @@ sealed record Variables(
};
return new Variables(
EnvironmentVariables.GetString("WEB_SERVER_HOST").WithDefault("0.0.0.0"),
EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").WithDefault(9400),
EnvironmentVariables.GetString("WEB_BASE_PATH").Validate(static value => value.StartsWith('/') && value.EndsWith('/'), "Environment variable must begin and end with '/'").WithDefault("/"),
EnvironmentVariables.GetString("RPC_SERVER_HOST").WithDefault("0.0.0.0"),
EnvironmentVariables.GetPortNumber("RPC_SERVER_PORT").WithDefault(9401),
connectionStringBuilder.ToString()

View File

@ -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();
}
}

View File

@ -5,7 +5,7 @@ services:
image: postgres:14
container_name: "phantom-panel-postgres"
ports:
- "127.0.0.1:9402:5432"
- "127.0.0.1:9403:5432"
volumes:
- postgres:/var/lib/postgresql/data
environment:

View File

@ -7,6 +7,7 @@ ARG TARGETARCH
ADD . /app
WORKDIR /app
RUN mkdir /data && chmod 777 /data
RUN dotnet restore --arch "$TARGETARCH"
@ -24,25 +25,31 @@ RUN dotnet publish Agent/Phantom.Agent/Phantom.Agent.csproj \
--output /app/out
# +----------------------+
# | Build Phantom Server |
# +----------------------+
FROM phantom-base-builder AS phantom-server-builder
RUN dotnet publish Web/Phantom.Web/Phantom.Web.csproj \
/p:DebugType=None \
/p:DebugSymbols=false \
--no-restore \
--arch "$TARGETARCH" \
--configuration Release \
--output /app/out
# +--------------------------+
# | Build Phantom Controller |
# +--------------------------+
FROM phantom-base-builder AS phantom-controller-builder
RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \
/p:DebugType=None \
/p:DebugSymbols=false \
--no-restore \
--arch "$TARGETARCH" \
--configuration Release \
/p:DebugType=None \
/p:DebugSymbols=false \
--no-restore \
--arch "$TARGETARCH" \
--configuration Release \
--output /app/out
# +-------------------+
# | Build Phantom Web |
# +-------------------+
FROM phantom-base-builder AS phantom-controller-builder
RUN dotnet publish Web/Phantom.Web/Phantom.Web.csproj \
/p:DebugType=None \
/p:DebugSymbols=false \
--no-restore \
--arch "$TARGETARCH" \
--configuration Release \
--output /app/out
@ -51,7 +58,6 @@ RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \
# +------------------------------+
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-agent
RUN mkdir /data && chmod 777 /data
WORKDIR /data
COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8
@ -73,14 +79,25 @@ COPY --from=phantom-agent-builder --chmod=755 /app/out /app
ENTRYPOINT ["dotnet", "/app/Phantom.Agent.dll"]
# +-------------------------------+
# | Finalize Phantom Server image |
# +-------------------------------+
FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0-preview AS phantom-server
# +-----------------------------------+
# | Finalize Phantom Controller image |
# +-----------------------------------+
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-controller
RUN mkdir /data && chmod 777 /data
WORKDIR /data
COPY --from=phantom-server-builder --chmod=755 /app/out /app
COPY --from=phantom-controller-builder --chmod=755 /app/out /app
ENTRYPOINT ["dotnet", "/app/Phantom.Controller.dll"]
# +----------------------------+
# | Finalize Phantom Web image |
# +----------------------------+
FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0-preview AS phantom-web
WORKDIR /data
COPY --from=phantom-web-builder --chmod=755 /app/out /app
ENTRYPOINT ["dotnet", "/app/Phantom.Web.dll"]

View File

@ -1,4 +1,4 @@
namespace Phantom.Web.Components.Utils;
namespace Phantom.Utils.Tasks;
public sealed class Throttler {
private readonly TimeSpan interval;

View File

@ -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);
}
}

View File

@ -20,8 +20,6 @@
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Controller\Phantom.Controller.Database\Phantom.Controller.Database.csproj" />
<ProjectReference Include="..\..\Controller\Phantom.Controller.Services\Phantom.Controller.Services.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" />
</ItemGroup>

View File

@ -1,8 +1,5 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Phantom.Controller.Database;
using Phantom.Web.Base;
using Phantom.Web.Components.Utils;
using Phantom.Web.Identity;
using Phantom.Web.Identity.Interfaces;
using Serilog;
@ -83,24 +80,6 @@ public static class Launcher {
}
}
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);