diff --git a/.run/Controller + Agent x3.run.xml b/.run/Controller + Web + Agent x3.run.xml similarity index 67% rename from .run/Controller + Agent x3.run.xml rename to .run/Controller + Web + Agent x3.run.xml index 2fe0300..110e96a 100644 --- a/.run/Controller + Agent x3.run.xml +++ b/.run/Controller + Web + Agent x3.run.xml @@ -1,9 +1,10 @@ <component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Controller + Agent x3" type="CompoundRunConfigurationType"> + <configuration default="false" name="Controller + Web + Agent x3" type="CompoundRunConfigurationType"> <toRun name="Agent 1" type="DotNetProject" /> <toRun name="Agent 2" type="DotNetProject" /> <toRun name="Agent 3" type="DotNetProject" /> <toRun name="Controller" type="DotNetProject" /> + <toRun name="Web" type="DotNetProject" /> <method v="2" /> </configuration> </component> \ No newline at end of file diff --git a/.run/Controller + Agent.run.xml b/.run/Controller + Web + Agent.run.xml similarity index 58% rename from .run/Controller + Agent.run.xml rename to .run/Controller + Web + Agent.run.xml index 2e299db..1178914 100644 --- a/.run/Controller + Agent.run.xml +++ b/.run/Controller + Web + Agent.run.xml @@ -1,7 +1,8 @@ <component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Controller + Agent" type="CompoundRunConfigurationType"> + <configuration default="false" name="Controller + Web + Agent" type="CompoundRunConfigurationType"> <toRun name="Agent 1" type="DotNetProject" /> <toRun name="Controller" type="DotNetProject" /> + <toRun name="Web" type="DotNetProject" /> <method v="2" /> </configuration> </component> \ No newline at end of file diff --git a/.run/Web.run.xml b/.run/Web.run.xml new file mode 100644 index 0000000..554e06f --- /dev/null +++ b/.run/Web.run.xml @@ -0,0 +1,26 @@ +<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="CONTROLLER_HOST" value="localhost" /> + <env name="WEB_KEY" value="BMNHM9RRPMCBBY29D9XHS6KBKZSRY7F5XFN27YZX96XXWJC2NM2D6YRHM9PZN9JGQGCSJ6FMB2GGZ" /> + <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> \ No newline at end of file diff --git a/Agent/Phantom.Agent/AgentKey.cs b/Agent/Phantom.Agent/AgentKey.cs index dd2ed1c..85776c3 100644 --- a/Agent/Phantom.Agent/AgentKey.cs +++ b/Agent/Phantom.Agent/AgentKey.cs @@ -1,5 +1,5 @@ using NetMQ; -using Phantom.Common.Data.Agent; +using Phantom.Common.Data; using Phantom.Common.Logging; using Phantom.Utils.Cryptography; using Phantom.Utils.IO; diff --git a/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs b/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs new file mode 100644 index 0000000..6aba38d --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Agent/AgentWithStats.cs @@ -0,0 +1,22 @@ +using MemoryPack; +using Phantom.Common.Data.Agent; + +namespace Phantom.Common.Data.Web.Agent; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record AgentWithStats( + [property: MemoryPackOrder(0)] Guid Guid, + [property: MemoryPackOrder(1)] string Name, + [property: MemoryPackOrder(2)] ushort ProtocolVersion, + [property: MemoryPackOrder(3)] string BuildVersion, + [property: MemoryPackOrder(4)] ushort MaxInstances, + [property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory, + [property: MemoryPackOrder(6)] AllowedPorts? AllowedServerPorts, + [property: MemoryPackOrder(7)] AllowedPorts? AllowedRconPorts, + [property: MemoryPackOrder(8)] AgentStats? Stats, + [property: MemoryPackOrder(9)] DateTimeOffset? LastPing, + [property: MemoryPackOrder(10)] bool IsOnline +) { + [MemoryPackIgnore] + public RamAllocationUnits? AvailableMemory => MaxMemory - Stats?.RunningInstanceMemory; +} diff --git a/Controller/Phantom.Controller.Database/Enums/AuditLogEventType.cs b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogEventType.cs similarity index 85% rename from Controller/Phantom.Controller.Database/Enums/AuditLogEventType.cs rename to Common/Phantom.Common.Data.Web/AuditLog/AuditLogEventType.cs index 060e588..c82bc26 100644 --- a/Controller/Phantom.Controller.Database/Enums/AuditLogEventType.cs +++ b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogEventType.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Database.Enums; +namespace Phantom.Common.Data.Web.AuditLog; public enum AuditLogEventType { AdministratorUserCreated, @@ -6,6 +6,7 @@ public enum AuditLogEventType { UserLoggedIn, UserLoggedOut, UserCreated, + UserPasswordChanged, UserRolesChanged, UserDeleted, InstanceCreated, @@ -15,13 +16,14 @@ public enum AuditLogEventType { InstanceCommandExecuted } -static class AuditLogEventTypeExtensions { +public static class AuditLogEventTypeExtensions { private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () { { AuditLogEventType.AdministratorUserCreated, AuditLogSubjectType.User }, { AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User }, { AuditLogEventType.UserLoggedIn, AuditLogSubjectType.User }, { AuditLogEventType.UserLoggedOut, AuditLogSubjectType.User }, { AuditLogEventType.UserCreated, AuditLogSubjectType.User }, + { AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserDeleted, AuditLogSubjectType.User }, { AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance }, @@ -39,7 +41,7 @@ static class AuditLogEventTypeExtensions { } } - internal static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) { + public static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) { return SubjectTypes[type]; } } diff --git a/Common/Phantom.Common.Data.Web/AuditLog/AuditLogItem.cs b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogItem.cs new file mode 100644 index 0000000..f5870cf --- /dev/null +++ b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogItem.cs @@ -0,0 +1,14 @@ +using MemoryPack; + +namespace Phantom.Common.Data.Web.AuditLog; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record AuditLogItem( + [property: MemoryPackOrder(0)] DateTime UtcTime, + [property: MemoryPackOrder(1)] Guid? UserGuid, + [property: MemoryPackOrder(2)] string? UserName, + [property: MemoryPackOrder(3)] AuditLogEventType EventType, + [property: MemoryPackOrder(4)] AuditLogSubjectType SubjectType, + [property: MemoryPackOrder(5)] string? SubjectId, + [property: MemoryPackOrder(6)] string? JsonData +); diff --git a/Controller/Phantom.Controller.Database/Enums/AuditLogSubjectType.cs b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogSubjectType.cs similarity index 52% rename from Controller/Phantom.Controller.Database/Enums/AuditLogSubjectType.cs rename to Common/Phantom.Common.Data.Web/AuditLog/AuditLogSubjectType.cs index 7bc84a7..34094f9 100644 --- a/Controller/Phantom.Controller.Database/Enums/AuditLogSubjectType.cs +++ b/Common/Phantom.Common.Data.Web/AuditLog/AuditLogSubjectType.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Database.Enums; +namespace Phantom.Common.Data.Web.AuditLog; public enum AuditLogSubjectType { User, diff --git a/Controller/Phantom.Controller.Database/Enums/EventLogEventType.cs b/Common/Phantom.Common.Data.Web/EventLog/EventLogEventType.cs similarity index 92% rename from Controller/Phantom.Controller.Database/Enums/EventLogEventType.cs rename to Common/Phantom.Common.Data.Web/EventLog/EventLogEventType.cs index b9d27f3..120f9c8 100644 --- a/Controller/Phantom.Controller.Database/Enums/EventLogEventType.cs +++ b/Common/Phantom.Common.Data.Web/EventLog/EventLogEventType.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Database.Enums; +namespace Phantom.Common.Data.Web.EventLog; public enum EventLogEventType { InstanceLaunchSucceded, @@ -10,7 +10,7 @@ public enum EventLogEventType { InstanceBackupFailed, } -static class EventLogEventTypeExtensions { +public static class EventLogEventTypeExtensions { private static readonly Dictionary<EventLogEventType, EventLogSubjectType> SubjectTypes = new () { { EventLogEventType.InstanceLaunchSucceded, EventLogSubjectType.Instance }, { EventLogEventType.InstanceLaunchFailed, EventLogSubjectType.Instance }, diff --git a/Common/Phantom.Common.Data.Web/EventLog/EventLogItem.cs b/Common/Phantom.Common.Data.Web/EventLog/EventLogItem.cs new file mode 100644 index 0000000..9aae689 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/EventLog/EventLogItem.cs @@ -0,0 +1,13 @@ +using MemoryPack; + +namespace Phantom.Common.Data.Web.EventLog; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record EventLogItem( + [property: MemoryPackOrder(0)] DateTime UtcTime, + [property: MemoryPackOrder(1)] Guid? AgentGuid, + [property: MemoryPackOrder(2)] EventLogEventType EventType, + [property: MemoryPackOrder(3)] EventLogSubjectType SubjectType, + [property: MemoryPackOrder(4)] string SubjectId, + [property: MemoryPackOrder(5)] string? JsonData +); diff --git a/Common/Phantom.Common.Data.Web/EventLog/EventLogSubjectType.cs b/Common/Phantom.Common.Data.Web/EventLog/EventLogSubjectType.cs new file mode 100644 index 0000000..06bebf9 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/EventLog/EventLogSubjectType.cs @@ -0,0 +1,5 @@ +namespace Phantom.Common.Data.Web.EventLog; + +public enum EventLogSubjectType { + Instance +} diff --git a/Common/Phantom.Common.Data.Web/Instance/CreateOrUpdateInstanceResult.cs b/Common/Phantom.Common.Data.Web/Instance/CreateOrUpdateInstanceResult.cs new file mode 100644 index 0000000..89dd4bc --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Instance/CreateOrUpdateInstanceResult.cs @@ -0,0 +1,23 @@ +namespace Phantom.Common.Data.Web.Instance; + +public enum CreateOrUpdateInstanceResult : byte { + UnknownError, + Success, + InstanceNameMustNotBeEmpty, + InstanceMemoryMustNotBeZero, + MinecraftVersionDownloadInfoNotFound, + AgentNotFound +} + +public static class CreateOrUpdateInstanceResultExtensions { + public static string ToSentence(this CreateOrUpdateInstanceResult reason) { + return reason switch { + CreateOrUpdateInstanceResult.Success => "Success.", + CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty => "Instance name must not be empty.", + CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.", + CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.", + CreateOrUpdateInstanceResult.AgentNotFound => "Agent not found.", + _ => "Unknown error." + }; + } +} diff --git a/Common/Phantom.Common.Data.Web/Instance/Instance.cs b/Common/Phantom.Common.Data.Web/Instance/Instance.cs new file mode 100644 index 0000000..fe2ac10 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Instance/Instance.cs @@ -0,0 +1,15 @@ +using MemoryPack; +using Phantom.Common.Data.Instance; + +namespace Phantom.Common.Data.Web.Instance; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record Instance( + [property: MemoryPackOrder(0)] InstanceConfiguration Configuration, + [property: MemoryPackOrder(1)] IInstanceStatus Status, + [property: MemoryPackOrder(2)] bool LaunchAutomatically +) { + public static Instance Offline(InstanceConfiguration configuration, bool launchAutomatically = false) { + return new Instance(configuration, InstanceStatus.Offline, launchAutomatically); + } +} diff --git a/Controller/Phantom.Controller.Minecraft/JvmArgumentsHelper.cs b/Common/Phantom.Common.Data.Web/Minecraft/JvmArgumentsHelper.cs similarity index 95% rename from Controller/Phantom.Controller.Minecraft/JvmArgumentsHelper.cs rename to Common/Phantom.Common.Data.Web/Minecraft/JvmArgumentsHelper.cs index d43d0ea..ab6e949 100644 --- a/Controller/Phantom.Controller.Minecraft/JvmArgumentsHelper.cs +++ b/Common/Phantom.Common.Data.Web/Minecraft/JvmArgumentsHelper.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace Phantom.Controller.Minecraft; +namespace Phantom.Common.Data.Web.Minecraft; public static class JvmArgumentsHelper { public static ImmutableArray<string> Split(string arguments) { diff --git a/Common/Phantom.Common.Data.Web/Phantom.Common.Data.Web.csproj b/Common/Phantom.Common.Data.Web/Phantom.Common.Data.Web.csproj new file mode 100644 index 0000000..b9fc41f --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Phantom.Common.Data.Web.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="BCrypt.Net-Next.StrongName" /> + <PackageReference Include="MemoryPack" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + </ItemGroup> + +</Project> diff --git a/Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs b/Common/Phantom.Common.Data.Web/Users/AddRoleError.cs similarity index 65% rename from Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs rename to Common/Phantom.Common.Data.Web/Users/AddRoleError.cs index df1d8d5..5c02d93 100644 --- a/Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs +++ b/Common/Phantom.Common.Data.Web/Users/AddRoleError.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Services.Users.Roles; +namespace Phantom.Common.Data.Web.Users; public enum AddRoleError : byte { NameIsEmpty, diff --git a/Common/Phantom.Common.Data.Web/Users/AddUserError.cs b/Common/Phantom.Common.Data.Web/Users/AddUserError.cs new file mode 100644 index 0000000..2e30852 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/AddUserError.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Users.AddUserErrors; + +namespace Phantom.Common.Data.Web.Users { + [MemoryPackable] + [MemoryPackUnion(0, typeof(NameIsInvalid))] + [MemoryPackUnion(1, typeof(PasswordIsInvalid))] + [MemoryPackUnion(2, typeof(NameAlreadyExists))] + [MemoryPackUnion(3, typeof(UnknownError))] + public abstract partial record AddUserError { + internal AddUserError() {} + } +} + +namespace Phantom.Common.Data.Web.Users.AddUserErrors { + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record NameAlreadyExists : AddUserError; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record UnknownError : AddUserError; +} diff --git a/Common/Phantom.Common.Data.Web/Users/ChangeUserRolesResult.cs b/Common/Phantom.Common.Data.Web/Users/ChangeUserRolesResult.cs new file mode 100644 index 0000000..e46eec0 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/ChangeUserRolesResult.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; +using MemoryPack; + +namespace Phantom.Common.Data.Web.Users; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record ChangeUserRolesResult( + [property: MemoryPackOrder(0)] ImmutableHashSet<Guid> AddedToRoleGuids, + [property: MemoryPackOrder(1)] ImmutableHashSet<Guid> RemovedFromRoleGuids +); diff --git a/Common/Phantom.Common.Data.Web/Users/CreateOrUpdateAdministratorUserResult.cs b/Common/Phantom.Common.Data.Web/Users/CreateOrUpdateAdministratorUserResult.cs new file mode 100644 index 0000000..d846e1c --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/CreateOrUpdateAdministratorUserResult.cs @@ -0,0 +1,31 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults; + +namespace Phantom.Common.Data.Web.Users { + [MemoryPackable] + [MemoryPackUnion(0, typeof(Success))] + [MemoryPackUnion(1, typeof(CreationFailed))] + [MemoryPackUnion(2, typeof(UpdatingFailed))] + [MemoryPackUnion(3, typeof(AddingToRoleFailed))] + [MemoryPackUnion(4, typeof(UnknownError))] + public abstract partial record CreateOrUpdateAdministratorUserResult { + internal CreateOrUpdateAdministratorUserResult() {} + } +} + +namespace Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults { + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateOrUpdateAdministratorUserResult; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record UpdatingFailed([property: MemoryPackOrder(0)] SetUserPasswordError Error) : CreateOrUpdateAdministratorUserResult; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record AddingToRoleFailed : CreateOrUpdateAdministratorUserResult; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record UnknownError : CreateOrUpdateAdministratorUserResult; +} diff --git a/Common/Phantom.Common.Data.Web/Users/CreateUserResult.cs b/Common/Phantom.Common.Data.Web/Users/CreateUserResult.cs new file mode 100644 index 0000000..1925971 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/CreateUserResult.cs @@ -0,0 +1,23 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users.CreateUserResults; + +namespace Phantom.Common.Data.Web.Users { + [MemoryPackable] + [MemoryPackUnion(0, typeof(Success))] + [MemoryPackUnion(1, typeof(CreationFailed))] + [MemoryPackUnion(2, typeof(UnknownError))] + public abstract partial record CreateUserResult { + internal CreateUserResult() {} + } +} + +namespace Phantom.Common.Data.Web.Users.CreateUserResults { + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateUserResult; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateUserResult; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record UnknownError : CreateUserResult; +} diff --git a/Controller/Phantom.Controller.Services/Users/DeleteUserResult.cs b/Common/Phantom.Common.Data.Web/Users/DeleteUserResult.cs similarity index 59% rename from Controller/Phantom.Controller.Services/Users/DeleteUserResult.cs rename to Common/Phantom.Common.Data.Web/Users/DeleteUserResult.cs index fcb6229..693e58d 100644 --- a/Controller/Phantom.Controller.Services/Users/DeleteUserResult.cs +++ b/Common/Phantom.Common.Data.Web/Users/DeleteUserResult.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Services.Users; +namespace Phantom.Common.Data.Web.Users; public enum DeleteUserResult : byte { Deleted, diff --git a/Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs b/Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs new file mode 100644 index 0000000..e09cc56 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/LogInSuccess.cs @@ -0,0 +1,11 @@ +using System.Collections.Immutable; +using MemoryPack; + +namespace Phantom.Common.Data.Web.Users; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record LogInSuccess ( + [property: MemoryPackOrder(0)] Guid UserGuid, + [property: MemoryPackOrder(1)] PermissionSet Permissions, + [property: MemoryPackOrder(2)] ImmutableArray<byte> Token +); diff --git a/Common/Phantom.Common.Data.Web/Users/PasswordRequirementViolation.cs b/Common/Phantom.Common.Data.Web/Users/PasswordRequirementViolation.cs new file mode 100644 index 0000000..c10c662 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/PasswordRequirementViolation.cs @@ -0,0 +1,27 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users.PasswordRequirementViolations; + +namespace Phantom.Common.Data.Web.Users { + [MemoryPackable] + [MemoryPackUnion(0, typeof(TooShort))] + [MemoryPackUnion(1, typeof(MustContainLowercaseLetter))] + [MemoryPackUnion(2, typeof(MustContainUppercaseLetter))] + [MemoryPackUnion(3, typeof(MustContainDigit))] + public abstract partial record PasswordRequirementViolation { + internal PasswordRequirementViolation() {} + } +} + +namespace Phantom.Common.Data.Web.Users.PasswordRequirementViolations { + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record MustContainLowercaseLetter : PasswordRequirementViolation; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record MustContainUppercaseLetter : PasswordRequirementViolation; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record MustContainDigit : PasswordRequirementViolation; +} diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs b/Common/Phantom.Common.Data.Web/Users/Permission.cs similarity index 96% rename from Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs rename to Common/Phantom.Common.Data.Web/Users/Permission.cs index 3489f6d..6220c89 100644 --- a/Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs +++ b/Common/Phantom.Common.Data.Web/Users/Permission.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Services.Users.Permissions; +namespace Phantom.Common.Data.Web.Users; public sealed record Permission(string Id, Permission? Parent) { private static readonly List<Permission> AllPermissions = new (); diff --git a/Common/Phantom.Common.Data.Web/Users/PermissionSet.cs b/Common/Phantom.Common.Data.Web/Users/PermissionSet.cs new file mode 100644 index 0000000..437f36c --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/PermissionSet.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using MemoryPack; + +namespace Phantom.Common.Data.Web.Users; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial class PermissionSet { + public static PermissionSet None { get; } = new (ImmutableHashSet<string>.Empty); + + [MemoryPackOrder(0)] + [MemoryPackInclude] + private readonly ImmutableHashSet<string> permissionIds; + + public PermissionSet(ImmutableHashSet<string> permissionIds) { + this.permissionIds = permissionIds; + } + + public bool Check(Permission? permission) { + while (permission != null) { + if (!permissionIds.Contains(permission.Id)) { + return false; + } + + permission = permission.Parent; + } + + return true; + } +} diff --git a/Common/Phantom.Common.Data.Web/Users/RoleInfo.cs b/Common/Phantom.Common.Data.Web/Users/RoleInfo.cs new file mode 100644 index 0000000..257223b --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/RoleInfo.cs @@ -0,0 +1,9 @@ +using MemoryPack; + +namespace Phantom.Common.Data.Web.Users; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record RoleInfo( + [property: MemoryPackOrder(0)] Guid Guid, + [property: MemoryPackOrder(1)] string Name +); diff --git a/Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs b/Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs new file mode 100644 index 0000000..1cc0252 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/SetUserPasswordError.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Users.SetUserPasswordErrors; + +namespace Phantom.Common.Data.Web.Users { + [MemoryPackable] + [MemoryPackUnion(0, typeof(UserNotFound))] + [MemoryPackUnion(1, typeof(PasswordIsInvalid))] + [MemoryPackUnion(2, typeof(UnknownError))] + public abstract partial record SetUserPasswordError { + internal SetUserPasswordError() {} + } +} + +namespace Phantom.Common.Data.Web.Users.SetUserPasswordErrors { + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record UserNotFound : SetUserPasswordError; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record UnknownError : SetUserPasswordError; +} diff --git a/Common/Phantom.Common.Data.Web/Users/UserInfo.cs b/Common/Phantom.Common.Data.Web/Users/UserInfo.cs new file mode 100644 index 0000000..0c1ef02 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/UserInfo.cs @@ -0,0 +1,9 @@ +using MemoryPack; + +namespace Phantom.Common.Data.Web.Users; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record UserInfo( + [property: MemoryPackOrder(0)] Guid Guid, + [property: MemoryPackOrder(1)] string Name +); diff --git a/Common/Phantom.Common.Data.Web/Users/UserPasswords.cs b/Common/Phantom.Common.Data.Web/Users/UserPasswords.cs new file mode 100644 index 0000000..962bb01 --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/UserPasswords.cs @@ -0,0 +1,12 @@ +namespace Phantom.Common.Data.Web.Users; + +public static class UserPasswords { + public static string Hash(string password) { + return BCrypt.Net.BCrypt.HashPassword(password, workFactor: 12); + } + + public static bool Verify(string password, string hash) { + // TODO rehash + return BCrypt.Net.BCrypt.Verify(password, hash); + } +} diff --git a/Common/Phantom.Common.Data.Web/Users/UsernameRequirementViolation.cs b/Common/Phantom.Common.Data.Web/Users/UsernameRequirementViolation.cs new file mode 100644 index 0000000..37433dd --- /dev/null +++ b/Common/Phantom.Common.Data.Web/Users/UsernameRequirementViolation.cs @@ -0,0 +1,19 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users.UsernameRequirementViolations; + +namespace Phantom.Common.Data.Web.Users { + [MemoryPackable] + [MemoryPackUnion(0, typeof(IsEmpty))] + [MemoryPackUnion(1, typeof(TooLong))] + public abstract partial record UsernameRequirementViolation { + internal UsernameRequirementViolation() {} + } +} + +namespace Phantom.Common.Data.Web.Users.UsernameRequirementViolations { + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record IsEmpty : UsernameRequirementViolation; + + [MemoryPackable(GenerateType.VersionTolerant)] + public sealed partial record TooLong([property: MemoryPackOrder(0)] int MaxLength) : UsernameRequirementViolation; +} diff --git a/Common/Phantom.Common.Data/Agent/AgentStats.cs b/Common/Phantom.Common.Data/Agent/AgentStats.cs new file mode 100644 index 0000000..4d3eb2e --- /dev/null +++ b/Common/Phantom.Common.Data/Agent/AgentStats.cs @@ -0,0 +1,9 @@ +using MemoryPack; + +namespace Phantom.Common.Data.Agent; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record AgentStats( + [property: MemoryPackOrder(0)] int RunningInstanceCount, + [property: MemoryPackOrder(1)] RamAllocationUnits RunningInstanceMemory +); diff --git a/Common/Phantom.Common.Data/Agent/AuthToken.cs b/Common/Phantom.Common.Data/AuthToken.cs similarity index 96% rename from Common/Phantom.Common.Data/Agent/AuthToken.cs rename to Common/Phantom.Common.Data/AuthToken.cs index 20aec72..34d6078 100644 --- a/Common/Phantom.Common.Data/Agent/AuthToken.cs +++ b/Common/Phantom.Common.Data/AuthToken.cs @@ -2,7 +2,7 @@ using System.Security.Cryptography; using MemoryPack; -namespace Phantom.Common.Data.Agent; +namespace Phantom.Common.Data; [MemoryPackable(GenerateType.VersionTolerant)] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] diff --git a/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs b/Common/Phantom.Common.Data/ConnectionCommonKey.cs similarity index 94% rename from Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs rename to Common/Phantom.Common.Data/ConnectionCommonKey.cs index 8eaea10..a2fbedb 100644 --- a/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs +++ b/Common/Phantom.Common.Data/ConnectionCommonKey.cs @@ -1,4 +1,4 @@ -namespace Phantom.Common.Data.Agent; +namespace Phantom.Common.Data; public readonly record struct ConnectionCommonKey(byte[] CertificatePublicKey, AuthToken AuthToken) { private const byte TokenLength = AuthToken.Length; diff --git a/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs b/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs index 8316552..64302a1 100644 --- a/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs +++ b/Common/Phantom.Common.Data/Minecraft/MinecraftVersion.cs @@ -1,7 +1,10 @@ -namespace Phantom.Common.Data.Minecraft; +using MemoryPack; -public sealed record MinecraftVersion( - string Id, - MinecraftVersionType Type, - string MetadataUrl +namespace Phantom.Common.Data.Minecraft; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record MinecraftVersion( + [property: MemoryPackOrder(0)] string Id, + [property: MemoryPackOrder(1)] MinecraftVersionType Type, + [property: MemoryPackOrder(2)] string MetadataUrl ); diff --git a/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs index d3fbcac..fbadb61 100644 --- a/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs @@ -1,4 +1,5 @@ using MemoryPack; +using Phantom.Common.Data; using Phantom.Common.Data.Agent; using Phantom.Utils.Rpc.Message; diff --git a/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs b/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs index b265869..49048e7 100644 --- a/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs +++ b/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs @@ -1,8 +1,35 @@ -using Phantom.Common.Messages.Web.BiDirectional; +using System.Collections.Immutable; +using Phantom.Common.Data.Java; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Common.Data.Web.EventLog; +using Phantom.Common.Data.Web.Instance; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Common.Messages.Web.ToController; using Phantom.Utils.Rpc.Message; namespace Phantom.Common.Messages.Web; public interface IMessageToControllerListener { + Task<NoReply> HandleRegisterWeb(RegisterWebMessage message); + Task<NoReply> HandleUnregisterWeb(UnregisterWebMessage message); + Task<LogInSuccess?> HandleLogIn(LogInMessage message); + Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message); + Task<CreateUserResult> HandleCreateUser(CreateUserMessage message); + Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message); + Task<ImmutableArray<RoleInfo>> HandleGetRoles(GetRolesMessage message); + Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> HandleGetUserRoles(GetUserRolesMessage message); + Task<ChangeUserRolesResult> HandleChangeUserRoles(ChangeUserRolesMessage message); + Task<DeleteUserResult> HandleDeleteUser(DeleteUserMessage message); + Task<InstanceActionResult<CreateOrUpdateInstanceResult>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message); + Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message); + Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message); + Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message); + Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message); + Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message); + Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message); + Task<ImmutableArray<EventLogItem>> HandleGetEventLog(GetEventLogMessage message); Task<NoReply> HandleReply(ReplyMessage message); } diff --git a/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs b/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs index 18822c6..eef5392 100644 --- a/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs +++ b/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs @@ -1,8 +1,13 @@ using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Common.Messages.Web.ToWeb; using Phantom.Utils.Rpc.Message; namespace Phantom.Common.Messages.Web; public interface IMessageToWebListener { + Task<NoReply> HandleRegisterWebResult(RegisterWebResultMessage message); + Task<NoReply> HandleRefreshAgents(RefreshAgentsMessage message); + Task<NoReply> HandleRefreshInstances(RefreshInstancesMessage message); + Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message); Task<NoReply> HandleReply(ReplyMessage message); } diff --git a/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj b/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj index cad9d69..4310038 100644 --- a/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj +++ b/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj @@ -8,6 +8,7 @@ <ItemGroup> <ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> <ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + <ProjectReference Include="..\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Rpc\Phantom.Utils.Rpc.csproj" /> </ItemGroup> diff --git a/Common/Phantom.Common.Messages.Web/ToController/ChangeUserRolesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/ChangeUserRolesMessage.cs new file mode 100644 index 0000000..9738705 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/ChangeUserRolesMessage.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record ChangeUserRolesMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] Guid SubjectUserGuid, + [property: MemoryPackOrder(2)] ImmutableHashSet<Guid> AddToRoleGuids, + [property: MemoryPackOrder(3)] ImmutableHashSet<Guid> RemoveFromRoleGuids +) : IMessageToController<ChangeUserRolesResult> { + public Task<ChangeUserRolesResult> Accept(IMessageToControllerListener listener) { + return listener.HandleChangeUserRoles(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateAdministratorUserMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateAdministratorUserMessage.cs new file mode 100644 index 0000000..0e9c607 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateAdministratorUserMessage.cs @@ -0,0 +1,14 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record CreateOrUpdateAdministratorUserMessage( + [property: MemoryPackOrder(0)] string Username, + [property: MemoryPackOrder(1)] string Password +) : IMessageToController<CreateOrUpdateAdministratorUserResult> { + public Task<CreateOrUpdateAdministratorUserResult> Accept(IMessageToControllerListener listener) { + return listener.HandleCreateOrUpdateAdministratorUser(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs new file mode 100644 index 0000000..f10ca68 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/CreateOrUpdateInstanceMessage.cs @@ -0,0 +1,16 @@ +using MemoryPack; +using Phantom.Common.Data.Instance; +using Phantom.Common.Data.Replies; +using Phantom.Common.Data.Web.Instance; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record CreateOrUpdateInstanceMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] InstanceConfiguration Configuration +) : IMessageToController<InstanceActionResult<CreateOrUpdateInstanceResult>> { + public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> Accept(IMessageToControllerListener listener) { + return listener.HandleCreateOrUpdateInstance(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/CreateUserMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/CreateUserMessage.cs new file mode 100644 index 0000000..85bd86e --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/CreateUserMessage.cs @@ -0,0 +1,15 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record CreateUserMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] string Username, + [property: MemoryPackOrder(2)] string Password +) : IMessageToController<CreateUserResult> { + public Task<CreateUserResult> Accept(IMessageToControllerListener listener) { + return listener.HandleCreateUser(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/DeleteUserMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/DeleteUserMessage.cs new file mode 100644 index 0000000..35728eb --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/DeleteUserMessage.cs @@ -0,0 +1,14 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record DeleteUserMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] Guid SubjectUserGuid +) : IMessageToController<DeleteUserResult> { + public Task<DeleteUserResult> Accept(IMessageToControllerListener listener) { + return listener.HandleDeleteUser(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetAgentJavaRuntimesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetAgentJavaRuntimesMessage.cs new file mode 100644 index 0000000..4cead49 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetAgentJavaRuntimesMessage.cs @@ -0,0 +1,12 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Java; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetAgentJavaRuntimesMessage : IMessageToController<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> { + public Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetAgentJavaRuntimes(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetAuditLogMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetAuditLogMessage.cs new file mode 100644 index 0000000..ad42f4a --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetAuditLogMessage.cs @@ -0,0 +1,14 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.AuditLog; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetAuditLogMessage( + [property: MemoryPackOrder(0)] int Count +) : IMessageToController<ImmutableArray<AuditLogItem>> { + public Task<ImmutableArray<AuditLogItem>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetAuditLog(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetEventLogMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetEventLogMessage.cs new file mode 100644 index 0000000..95e5b8d --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetEventLogMessage.cs @@ -0,0 +1,14 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.EventLog; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetEventLogMessage( + [property: MemoryPackOrder(0)] int Count +) : IMessageToController<ImmutableArray<EventLogItem>> { + public Task<ImmutableArray<EventLogItem>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetEventLog(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetMinecraftVersionsMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetMinecraftVersionsMessage.cs new file mode 100644 index 0000000..6e4b67a --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetMinecraftVersionsMessage.cs @@ -0,0 +1,12 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Minecraft; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetMinecraftVersionsMessage : IMessageToController<ImmutableArray<MinecraftVersion>> { + public Task<ImmutableArray<MinecraftVersion>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetMinecraftVersions(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetRolesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetRolesMessage.cs new file mode 100644 index 0000000..d5b359e --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetRolesMessage.cs @@ -0,0 +1,12 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetRolesMessage : IMessageToController<ImmutableArray<RoleInfo>> { + public Task<ImmutableArray<RoleInfo>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetRoles(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetUserRolesMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetUserRolesMessage.cs new file mode 100644 index 0000000..b8d6575 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetUserRolesMessage.cs @@ -0,0 +1,13 @@ +using System.Collections.Immutable; +using MemoryPack; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetUserRolesMessage( + [property: MemoryPackOrder(0)] ImmutableHashSet<Guid> UserGuids +) : IMessageToController<ImmutableDictionary<Guid, ImmutableArray<Guid>>> { + public Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetUserRoles(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/GetUsersMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/GetUsersMessage.cs new file mode 100644 index 0000000..05b6ee1 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/GetUsersMessage.cs @@ -0,0 +1,12 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record GetUsersMessage : IMessageToController<ImmutableArray<UserInfo>> { + public Task<ImmutableArray<UserInfo>> Accept(IMessageToControllerListener listener) { + return listener.HandleGetUsers(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs new file mode 100644 index 0000000..7b2ab9d --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/LaunchInstanceMessage.cs @@ -0,0 +1,14 @@ +using MemoryPack; +using Phantom.Common.Data.Replies; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record LaunchInstanceMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] Guid InstanceGuid +) : IMessageToController<InstanceActionResult<LaunchInstanceResult>> { + public Task<InstanceActionResult<LaunchInstanceResult>> Accept(IMessageToControllerListener listener) { + return listener.HandleLaunchInstance(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/LogInMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/LogInMessage.cs new file mode 100644 index 0000000..5b94a0b --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/LogInMessage.cs @@ -0,0 +1,14 @@ +using MemoryPack; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record LogInMessage( + [property: MemoryPackOrder(0)] string Username, + [property: MemoryPackOrder(1)] string Password +) : IMessageToController<LogInSuccess?> { + public Task<LogInSuccess?> Accept(IMessageToControllerListener listener) { + return listener.HandleLogIn(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/RegisterWebMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/RegisterWebMessage.cs new file mode 100644 index 0000000..6dfb29b --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/RegisterWebMessage.cs @@ -0,0 +1,14 @@ +using MemoryPack; +using Phantom.Common.Data; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record RegisterWebMessage( + [property: MemoryPackOrder(0)] AuthToken AuthToken +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { + return listener.HandleRegisterWeb(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs new file mode 100644 index 0000000..8295510 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/SendCommandToInstanceMessage.cs @@ -0,0 +1,15 @@ +using MemoryPack; +using Phantom.Common.Data.Replies; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record SendCommandToInstanceMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] Guid InstanceGuid, + [property: MemoryPackOrder(2)] string Command +) : IMessageToController<InstanceActionResult<SendCommandToInstanceResult>> { + public Task<InstanceActionResult<SendCommandToInstanceResult>> Accept(IMessageToControllerListener listener) { + return listener.HandleSendCommandToInstance(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs new file mode 100644 index 0000000..adc986d --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/StopInstanceMessage.cs @@ -0,0 +1,16 @@ +using MemoryPack; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record StopInstanceMessage( + [property: MemoryPackOrder(0)] Guid LoggedInUserGuid, + [property: MemoryPackOrder(1)] Guid InstanceGuid, + [property: MemoryPackOrder(2)] MinecraftStopStrategy StopStrategy +) : IMessageToController<InstanceActionResult<StopInstanceResult>> { + public Task<InstanceActionResult<StopInstanceResult>> Accept(IMessageToControllerListener listener) { + return listener.HandleStopInstance(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToController/UnregisterWebMessage.cs b/Common/Phantom.Common.Messages.Web/ToController/UnregisterWebMessage.cs new file mode 100644 index 0000000..1142448 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToController/UnregisterWebMessage.cs @@ -0,0 +1,11 @@ +using MemoryPack; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record UnregisterWebMessage : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { + return listener.HandleUnregisterWeb(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/InstanceOutputMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/InstanceOutputMessage.cs new file mode 100644 index 0000000..2e7cd19 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToWeb/InstanceOutputMessage.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.ToWeb; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record InstanceOutputMessage( + [property: MemoryPackOrder(0)] Guid InstanceGuid, + [property: MemoryPackOrder(1)] ImmutableArray<string> Lines +) : IMessageToWeb { + public Task<NoReply> Accept(IMessageToWebListener listener) { + return listener.HandleInstanceOutput(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs new file mode 100644 index 0000000..807d438 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshAgentsMessage.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Agent; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.ToWeb; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record RefreshAgentsMessage( + [property: MemoryPackOrder(0)] ImmutableArray<AgentWithStats> Agents +) : IMessageToWeb { + public Task<NoReply> Accept(IMessageToWebListener listener) { + return listener.HandleRefreshAgents(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RefreshInstancesMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshInstancesMessage.cs new file mode 100644 index 0000000..d5ce8f8 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToWeb/RefreshInstancesMessage.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; +using MemoryPack; +using Phantom.Common.Data.Web.Instance; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.ToWeb; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record RefreshInstancesMessage( + [property: MemoryPackOrder(0)] ImmutableArray<Instance> Instances +) : IMessageToWeb { + public Task<NoReply> Accept(IMessageToWebListener listener) { + return listener.HandleRefreshInstances(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/ToWeb/RegisterWebResultMessage.cs b/Common/Phantom.Common.Messages.Web/ToWeb/RegisterWebResultMessage.cs new file mode 100644 index 0000000..807b2ec --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/ToWeb/RegisterWebResultMessage.cs @@ -0,0 +1,13 @@ +using MemoryPack; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.ToWeb; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record RegisterWebResultMessage( + [property: MemoryPackOrder(0)] bool Success +) : IMessageToWeb { + public Task<NoReply> Accept(IMessageToWebListener listener) { + return listener.HandleRegisterWebResult(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs b/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs index a3b82d5..6aed657 100644 --- a/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs +++ b/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs @@ -1,5 +1,15 @@ -using Phantom.Common.Logging; +using System.Collections.Immutable; +using Phantom.Common.Data.Java; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Common.Data.Web.EventLog; +using Phantom.Common.Data.Web.Instance; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Logging; using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Common.Messages.Web.ToWeb; using Phantom.Utils.Rpc.Message; namespace Phantom.Common.Messages.Web; @@ -11,8 +21,30 @@ public static class WebMessageRegistries { public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions(); static WebMessageRegistries() { + ToController.Add<RegisterWebMessage>(0); + ToController.Add<UnregisterWebMessage>(1); + ToController.Add<LogInMessage, LogInSuccess?>(2); + ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(3); + ToController.Add<CreateUserMessage, CreateUserResult>(4); + ToController.Add<DeleteUserMessage, DeleteUserResult>(5); + ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(6); + ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(7); + ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(8); + ToController.Add<ChangeUserRolesMessage, ChangeUserRolesResult>(9); + ToController.Add<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(10); + ToController.Add<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(11); + ToController.Add<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(12); + ToController.Add<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(13); + ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(14); + ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(15); + ToController.Add<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(16); + ToController.Add<GetEventLogMessage, ImmutableArray<EventLogItem>>(17); ToController.Add<ReplyMessage>(127); + ToWeb.Add<RegisterWebResultMessage>(0); + ToWeb.Add<RefreshAgentsMessage>(1); + ToWeb.Add<RefreshInstancesMessage>(2); + ToWeb.Add<InstanceOutputMessage>(3); ToWeb.Add<ReplyMessage>(127); } @@ -21,7 +53,7 @@ public static class WebMessageRegistries { public MessageRegistry<IMessageToControllerListener> ToServer => ToController; public bool IsRegistrationMessage(Type messageType) { - return false; + return messageType == typeof(RegisterWebMessage); } public ReplyMessage CreateReplyMessage(uint sequenceId, byte[] serializedReply) { diff --git a/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs b/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs index 3f471a6..b4a15c6 100644 --- a/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs +++ b/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs @@ -4,17 +4,21 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; namespace Phantom.Controller.Database.Postgres; -public sealed class ApplicationDbContextFactory : IDatabaseProvider { +public sealed class ApplicationDbContextFactory : IDbContextProvider { private readonly PooledDbContextFactory<ApplicationDbContext> factory; public ApplicationDbContextFactory(string connectionString) { this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32); } - public ApplicationDbContext Provide() { + public ApplicationDbContext Eager() { return factory.CreateDbContext(); } + public ILazyDbContext Lazy() { + return new LazyDbContext(this); + } + private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) { var builder = new DbContextOptionsBuilder<ApplicationDbContext>(); builder.UseNpgsql(connectionString, ConfigureOptions); diff --git a/Controller/Phantom.Controller.Database.Postgres/LazyDbContext.cs b/Controller/Phantom.Controller.Database.Postgres/LazyDbContext.cs new file mode 100644 index 0000000..8cef848 --- /dev/null +++ b/Controller/Phantom.Controller.Database.Postgres/LazyDbContext.cs @@ -0,0 +1,16 @@ +namespace Phantom.Controller.Database.Postgres; + +sealed class LazyDbContext : ILazyDbContext { + public ApplicationDbContext Ctx => cachedContext ??= contextFactory.Eager(); + + private readonly ApplicationDbContextFactory contextFactory; + private ApplicationDbContext? cachedContext; + + internal LazyDbContext(ApplicationDbContextFactory contextFactory) { + this.contextFactory = contextFactory; + } + + public ValueTask DisposeAsync() { + return cachedContext?.DisposeAsync() ?? ValueTask.CompletedTask; + } +} diff --git a/Controller/Phantom.Controller.Database/ApplicationDbContext.cs b/Controller/Phantom.Controller.Database/ApplicationDbContext.cs index 09ea8ca..57b770e 100644 --- a/Controller/Phantom.Controller.Database/ApplicationDbContext.cs +++ b/Controller/Phantom.Controller.Database/ApplicationDbContext.cs @@ -3,9 +3,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Phantom.Common.Data; using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Common.Data.Web.EventLog; using Phantom.Controller.Database.Converters; using Phantom.Controller.Database.Entities; -using Phantom.Controller.Database.Enums; using Phantom.Controller.Database.Factories; namespace Phantom.Controller.Database; diff --git a/Controller/Phantom.Controller.Database/DatabaseMigrator.cs b/Controller/Phantom.Controller.Database/DatabaseMigrator.cs index 8a1ed1b..6deeb3d 100644 --- a/Controller/Phantom.Controller.Database/DatabaseMigrator.cs +++ b/Controller/Phantom.Controller.Database/DatabaseMigrator.cs @@ -8,8 +8,8 @@ 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(); + public static async Task Run(IDbContextProvider dbProvider, CancellationToken cancellationToken) { + await using var ctx = dbProvider.Eager(); Logger.Information("Connecting to database..."); diff --git a/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs b/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs index ff8e835..255e558 100644 --- a/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs +++ b/Controller/Phantom.Controller.Database/Entities/AuditLogEntity.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using Phantom.Controller.Database.Enums; +using Phantom.Common.Data.Web.AuditLog; namespace Phantom.Controller.Database.Entities; diff --git a/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs b/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs index 1b6dfe5..361bd03 100644 --- a/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs +++ b/Controller/Phantom.Controller.Database/Entities/EventLogEntity.cs @@ -2,7 +2,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using Phantom.Controller.Database.Enums; +using Phantom.Common.Data.Web.EventLog; namespace Phantom.Controller.Database.Entities; diff --git a/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs b/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs index fe563b6..b76c361 100644 --- a/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs +++ b/Controller/Phantom.Controller.Database/Entities/RoleEntity.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Phantom.Common.Data.Web.Users; namespace Phantom.Controller.Database.Entities; @@ -14,4 +15,8 @@ public sealed class RoleEntity { RoleGuid = roleGuid; Name = name; } + + public RoleInfo ToRoleInfo() { + return new RoleInfo(RoleGuid, Name); + } } diff --git a/Controller/Phantom.Controller.Database/Entities/UserEntity.cs b/Controller/Phantom.Controller.Database/Entities/UserEntity.cs index f590523..da4b72c 100644 --- a/Controller/Phantom.Controller.Database/Entities/UserEntity.cs +++ b/Controller/Phantom.Controller.Database/Entities/UserEntity.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using Phantom.Common.Data.Web.Users; namespace Phantom.Controller.Database.Entities; @@ -11,9 +12,13 @@ public sealed class UserEntity { public string Name { get; set; } public string PasswordHash { get; set; } - public UserEntity(Guid userGuid, string name) { + public UserEntity(Guid userGuid, string name, string passwordHash) { UserGuid = userGuid; Name = name; - PasswordHash = null!; + PasswordHash = passwordHash; + } + + public UserInfo ToUserInfo() { + return new UserInfo(UserGuid, Name); } } diff --git a/Controller/Phantom.Controller.Database/Enums/EventLogSubjectType.cs b/Controller/Phantom.Controller.Database/Enums/EventLogSubjectType.cs deleted file mode 100644 index 2005202..0000000 --- a/Controller/Phantom.Controller.Database/Enums/EventLogSubjectType.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Phantom.Controller.Database.Enums; - -public enum EventLogSubjectType { - Instance -} diff --git a/Controller/Phantom.Controller.Database/IDatabaseProvider.cs b/Controller/Phantom.Controller.Database/IDatabaseProvider.cs deleted file mode 100644 index bb8a395..0000000 --- a/Controller/Phantom.Controller.Database/IDatabaseProvider.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Phantom.Controller.Database; - -public interface IDatabaseProvider { - ApplicationDbContext Provide(); -} diff --git a/Controller/Phantom.Controller.Database/IDbContextProvider.cs b/Controller/Phantom.Controller.Database/IDbContextProvider.cs new file mode 100644 index 0000000..33cd1f1 --- /dev/null +++ b/Controller/Phantom.Controller.Database/IDbContextProvider.cs @@ -0,0 +1,6 @@ +namespace Phantom.Controller.Database; + +public interface IDbContextProvider { + ApplicationDbContext Eager(); + ILazyDbContext Lazy(); +} diff --git a/Controller/Phantom.Controller.Database/ILazyDbContext.cs b/Controller/Phantom.Controller.Database/ILazyDbContext.cs new file mode 100644 index 0000000..c4ca69b --- /dev/null +++ b/Controller/Phantom.Controller.Database/ILazyDbContext.cs @@ -0,0 +1,5 @@ +namespace Phantom.Controller.Database; + +public interface ILazyDbContext : IAsyncDisposable { + ApplicationDbContext Ctx { get; } +} diff --git a/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj b/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj index bf57ab9..a5536e2 100644 --- a/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj +++ b/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj @@ -11,10 +11,12 @@ <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> + <PackageReference Include="System.Linq.Async" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> </ItemGroup> diff --git a/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.Writer.cs b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.Writer.cs new file mode 100644 index 0000000..2721705 --- /dev/null +++ b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.Writer.cs @@ -0,0 +1,88 @@ +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Controller.Database.Entities; + +namespace Phantom.Controller.Database.Repositories; + +sealed partial class AuditLogRepository { + public sealed class ItemWriter { + private readonly ILazyDbContext db; + private readonly Guid? currentUserGuid; + + internal ItemWriter(ILazyDbContext db, Guid? currentUserGuid) { + this.db = db; + this.currentUserGuid = currentUserGuid; + } + + private void AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { + db.Ctx.AuditLog.Add(new AuditLogEntity(currentUserGuid, eventType, subjectId, extra)); + } + + public void UserLoggedIn(UserEntity user) { + AddItem(AuditLogEventType.UserLoggedIn, user.UserGuid.ToString()); + } + + public void UserLoggedOut(Guid userGuid) { + AddItem(AuditLogEventType.UserLoggedOut, userGuid.ToString()); + } + + public void AdministratorUserCreated(UserEntity user) { + AddItem(AuditLogEventType.AdministratorUserCreated, user.UserGuid.ToString()); + } + + public void AdministratorUserModified(UserEntity user) { + AddItem(AuditLogEventType.AdministratorUserCreated, user.UserGuid.ToString()); + } + + public void UserCreated(UserEntity user) { + AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString()); + } + + public void UserPasswordChanged(UserEntity user) { + AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString()); + } + + public void UserRolesChanged(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) { + var extra = new Dictionary<string, object?>(); + + if (addedToRoles.Count > 0) { + extra["addedToRoles"] = addedToRoles; + } + + if (removedFromRoles.Count > 0) { + extra["removedFromRoles"] = removedFromRoles; + } + + AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra); + } + + public void UserDeleted(UserEntity user) { + AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> { + { "username", user.Name } + }); + } + + public void InstanceCreated(Guid instanceGuid) { + AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); + } + + public void InstanceEdited(Guid instanceGuid) { + AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString()); + } + + public void InstanceLaunched(Guid instanceGuid) { + AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString()); + } + + public void InstanceCommandExecuted(Guid instanceGuid, string command) { + AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> { + { "command", command } + }); + } + + public void InstanceStopped(Guid instanceGuid, int stopInSeconds) { + AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> { + { "stop_in_seconds", stopInSeconds.ToString() } + }); + } + } +} diff --git a/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.cs b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.cs new file mode 100644 index 0000000..86cace6 --- /dev/null +++ b/Controller/Phantom.Controller.Database/Repositories/AuditLogRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Utils.Collections; + +namespace Phantom.Controller.Database.Repositories; + +public sealed partial class AuditLogRepository { + private readonly ILazyDbContext db; + + public AuditLogRepository(ILazyDbContext db) { + this.db = db; + } + + public Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) { + return db.Ctx + .AuditLog + .Include(static entity => entity.User) + .AsQueryable() + .OrderByDescending(static entity => entity.UtcTime) + .Take(count) + .AsAsyncEnumerable() + .Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User?.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString())) + .ToImmutableArrayAsync(cancellationToken); + } + + public ItemWriter Writer(Guid? currentUserGuid) { + return new ItemWriter(db, currentUserGuid); + } +} diff --git a/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs b/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs new file mode 100644 index 0000000..b861511 --- /dev/null +++ b/Controller/Phantom.Controller.Database/Repositories/EventLogRepository.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.EventLog; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; + +namespace Phantom.Controller.Database.Repositories; + +public sealed class EventLogRepository { + private readonly ILazyDbContext db; + + public EventLogRepository(ILazyDbContext db) { + this.db = db; + } + + public void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { + db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra)); + } + + public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) { + return db.Ctx + .EventLog + .AsQueryable() + .OrderByDescending(static entity => entity.UtcTime) + .Take(count) + .AsAsyncEnumerable() + .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString())) + .ToImmutableArrayAsync(cancellationToken); + } +} diff --git a/Controller/Phantom.Controller.Database/Repositories/RoleRepository.cs b/Controller/Phantom.Controller.Database/Repositories/RoleRepository.cs new file mode 100644 index 0000000..735f781 --- /dev/null +++ b/Controller/Phantom.Controller.Database/Repositories/RoleRepository.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.Users; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; +using Phantom.Utils.Tasks; + +namespace Phantom.Controller.Database.Repositories; + +public sealed class RoleRepository { + private const int MaxRoleNameLength = 40; + + private readonly ILazyDbContext db; + + public RoleRepository(ILazyDbContext db) { + this.db = db; + } + + public Task<ImmutableArray<RoleEntity>> GetAll() { + return db.Ctx.Roles.AsAsyncEnumerable().ToImmutableArrayAsync(); + } + + public Task<ImmutableDictionary<Guid, RoleEntity>> GetByGuids(ImmutableHashSet<Guid> guids) { + return db.Ctx.Roles + .Where(role => guids.Contains(role.RoleGuid)) + .AsAsyncEnumerable() + .ToImmutableDictionaryAsync(static role => role.RoleGuid, static role => role); + } + + public ValueTask<RoleEntity?> GetByGuid(Guid guid) { + return db.Ctx.Roles.FindAsync(guid); + } + + public async Task<Result<RoleEntity, AddRoleError>> Create(string name) { + if (string.IsNullOrWhiteSpace(name)) { + return AddRoleError.NameIsEmpty; + } + else if (name.Length > MaxRoleNameLength) { + return AddRoleError.NameIsTooLong; + } + + if (await db.Ctx.Roles.AnyAsync(role => role.Name == name)) { + return AddRoleError.NameAlreadyExists; + } + + var role = new RoleEntity(Guid.NewGuid(), name); + db.Ctx.Roles.Add(role); + return role; + } +} diff --git a/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs b/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs new file mode 100644 index 0000000..59b94c8 --- /dev/null +++ b/Controller/Phantom.Controller.Database/Repositories/UserRepository.cs @@ -0,0 +1,109 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Data.Web.Users.AddUserErrors; +using Phantom.Common.Data.Web.Users.PasswordRequirementViolations; +using Phantom.Common.Data.Web.Users.UsernameRequirementViolations; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; +using Phantom.Utils.Tasks; + +namespace Phantom.Controller.Database.Repositories; + +public sealed class UserRepository { + private const int MaxUserNameLength = 40; + private const int MinimumPasswordLength = 16; + + private static UsernameRequirementViolation? CheckUsernameRequirements(string username) { + if (string.IsNullOrWhiteSpace(username)) { + return new IsEmpty(); + } + else if (username.Length > MaxUserNameLength) { + return new TooLong(MaxUserNameLength); + } + else { + return null; + } + } + + private static ImmutableArray<PasswordRequirementViolation> CheckPasswordRequirements(string password) { + var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>(); + + if (password.Length < MinimumPasswordLength) { + violations.Add(new TooShort(MinimumPasswordLength)); + } + + if (!password.Any(char.IsLower)) { + violations.Add(new MustContainLowercaseLetter()); + } + + if (!password.Any(char.IsUpper)) { + violations.Add(new MustContainUppercaseLetter()); + } + + if (!password.Any(char.IsDigit)) { + violations.Add(new MustContainDigit()); + } + + return violations.ToImmutable(); + } + + private readonly ILazyDbContext db; + + public UserRepository(ILazyDbContext db) { + this.db = db; + } + + public Task<ImmutableArray<UserEntity>> GetAll() { + return db.Ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync(); + } + + public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) { + return db.Ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken); + } + + public async Task<UserEntity?> GetByGuid(Guid guid) { + return await db.Ctx.Users.FindAsync(guid); + } + + public Task<UserEntity?> GetByName(string username) { + return db.Ctx.Users.FirstOrDefaultAsync(user => user.Name == username); + } + + public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { + var usernameRequirementViolation = CheckUsernameRequirements(username); + if (usernameRequirementViolation != null) { + return new NameIsInvalid(usernameRequirementViolation); + } + + var passwordRequirementViolations = CheckPasswordRequirements(password); + if (!passwordRequirementViolations.IsEmpty) { + return new PasswordIsInvalid(passwordRequirementViolations); + } + + if (await db.Ctx.Users.AnyAsync(user => user.Name == username)) { + return new NameAlreadyExists(); + } + + var user = new UserEntity(Guid.NewGuid(), username, UserPasswords.Hash(password)); + + db.Ctx.Users.Add(user); + + return user; + } + + public Result<SetUserPasswordError> SetUserPassword(UserEntity user, string password) { + var requirementViolations = CheckPasswordRequirements(password); + if (!requirementViolations.IsEmpty) { + return new Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid(requirementViolations); + } + + user.PasswordHash = UserPasswords.Hash(password); + + return Result.Ok<SetUserPasswordError>(); + } + + public void DeleteUser(UserEntity user) { + db.Ctx.Users.Remove(user); + } +} diff --git a/Controller/Phantom.Controller.Database/Repositories/UserRoleRepository.cs b/Controller/Phantom.Controller.Database/Repositories/UserRoleRepository.cs new file mode 100644 index 0000000..19e3ead --- /dev/null +++ b/Controller/Phantom.Controller.Database/Repositories/UserRoleRepository.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; + +namespace Phantom.Controller.Database.Repositories; + +public sealed class UserRoleRepository { + private readonly ILazyDbContext db; + + public UserRoleRepository(ILazyDbContext db) { + this.db = db; + } + + public async Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetRoleGuidsByUserGuid(ImmutableHashSet<Guid> userGuids) { + var result = await db.Ctx.UserRoles + .Where(ur => userGuids.Contains(ur.UserGuid)) + .GroupBy(static ur => ur.UserGuid, static ur => ur.RoleGuid) + .AsAsyncEnumerable() + .ToImmutableDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray()); + + foreach (var userGuid in userGuids) { + if (!result.ContainsKey(userGuid)) { + result = result.Add(userGuid, ImmutableArray<Guid>.Empty); + } + } + + return result; + } + + public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() { + return db.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 Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) { + return db.Ctx.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.Ctx.UserRoles + .Where(ur => ur.UserGuid == user.UserGuid) + .Select(static ur => ur.RoleGuid) + .AsAsyncEnumerable() + .ToImmutableSetAsync(); + } + + public async Task Add(UserEntity user, RoleEntity role) { + var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); + if (userRole == null) { + db.Ctx.UserRoles.Add(new UserRoleEntity(user.UserGuid, role.RoleGuid)); + } + } + + public async Task<UserRoleEntity?> Remove(UserEntity user, RoleEntity role) { + var userRole = await db.Ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); + if (userRole == null) { + return null; + } + else { + db.Ctx.UserRoles.Remove(userRole); + return userRole; + } + } +} diff --git a/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs b/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs index 8fd5ed4..41c2e0f 100644 --- a/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs +++ b/Controller/Phantom.Controller.Rpc/RpcClientConnectionClosedEventArgs.cs @@ -1,9 +1,9 @@ namespace Phantom.Controller.Rpc; -sealed class RpcClientConnectionClosedEventArgs : EventArgs { - public uint RoutingId { get; } +public sealed class RpcClientConnectionClosedEventArgs : EventArgs { + internal uint RoutingId { get; } - public RpcClientConnectionClosedEventArgs(uint routingId) { + internal RpcClientConnectionClosedEventArgs(uint routingId) { RoutingId = routingId; } } diff --git a/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs b/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs index 8be4f1a..895c205 100644 --- a/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs +++ b/Controller/Phantom.Controller.Rpc/RpcConnectionToClient.cs @@ -19,7 +19,7 @@ public sealed class RpcConnectionToClient<TListener> { } internal event EventHandler<RpcClientConnectionClosedEventArgs>? Closed; - private bool isClosed; + public bool IsClosed { get; private set; } internal RpcConnectionToClient(ServerSocket socket, uint routingId, MessageRegistry<TListener> messageRegistry, MessageReplyTracker messageReplyTracker) { this.socket = socket; @@ -33,16 +33,22 @@ public sealed class RpcConnectionToClient<TListener> { } public void Close() { + bool hasClosed = false; + lock (this) { - if (!isClosed) { - isClosed = true; - Closed?.Invoke(this, new RpcClientConnectionClosedEventArgs(routingId)); + if (!IsClosed) { + IsClosed = true; + hasClosed = true; } } + + if (hasClosed) { + Closed?.Invoke(this, new RpcClientConnectionClosedEventArgs(routingId)); + } } public async Task Send<TMessage>(TMessage message) where TMessage : IMessage<TListener, NoReply> { - if (isClosed) { + if (IsClosed) { return; } @@ -53,7 +59,7 @@ public sealed class RpcConnectionToClient<TListener> { } public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class { - if (isClosed) { + if (IsClosed) { return null; } diff --git a/Controller/Phantom.Controller.Services/Agents/Agent.cs b/Controller/Phantom.Controller.Services/Agents/Agent.cs index 76c649b..59ee355 100644 --- a/Controller/Phantom.Controller.Services/Agents/Agent.cs +++ b/Controller/Phantom.Controller.Services/Agents/Agent.cs @@ -20,8 +20,6 @@ public sealed record Agent( public bool IsOnline { get; internal init; } public bool IsOffline => !IsOnline; - public RamAllocationUnits? AvailableMemory => MaxMemory - Stats?.RunningInstanceMemory; - internal Agent(AgentInfo info) : this(info.Guid, info.Name, info.ProtocolVersion, info.BuildVersion, info.MaxInstances, info.MaxMemory, info.AllowedServerPorts, info.AllowedRconPorts) {} internal Agent AsOnline(DateTimeOffset lastPing) => this with { diff --git a/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs index bcc2043..9083a4c 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentJavaRuntimesManager.cs @@ -4,7 +4,7 @@ using Phantom.Utils.Collections; namespace Phantom.Controller.Services.Agents; -public sealed class AgentJavaRuntimesManager { +sealed class AgentJavaRuntimesManager { private readonly RwLockedDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> runtimes = new (LockRecursionPolicy.NoRecursion); public ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> All => runtimes.ToImmutable(); diff --git a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs index 111c877..884c7ce 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs @@ -11,11 +11,11 @@ using Phantom.Controller.Services.Instances; using Phantom.Utils.Collections; using Phantom.Utils.Events; using Phantom.Utils.Tasks; -using ILogger = Serilog.ILogger; +using Serilog; namespace Phantom.Controller.Services.Agents; -public sealed class AgentManager { +sealed class AgentManager { private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>(); private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5); @@ -27,17 +27,17 @@ public sealed class AgentManager { private readonly CancellationToken cancellationToken; private readonly AuthToken authToken; - private readonly IDatabaseProvider databaseProvider; + private readonly IDbContextProvider dbProvider; - public AgentManager(AuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { + public AgentManager(AuthToken authToken, IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) { this.authToken = authToken; - this.databaseProvider = databaseProvider; + this.dbProvider = dbProvider; this.cancellationToken = cancellationToken; taskManager.Run("Refresh agent status loop", RefreshAgentStatus); } internal async Task Initialize() { - await using var ctx = databaseProvider.Provide(); + await using var ctx = dbProvider.Eager(); 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); @@ -68,7 +68,7 @@ public sealed class AgentManager { oldAgent.Connection?.Close(); } - await using (var ctx = databaseProvider.Provide()) { + await using (var ctx = dbProvider.Eager()) { var entity = ctx.AgentUpsert.Fetch(agent.Guid); entity.Name = agent.Name; diff --git a/Controller/Phantom.Controller.Services/Agents/AgentStats.cs b/Controller/Phantom.Controller.Services/Agents/AgentStats.cs deleted file mode 100644 index 0e1d8c7..0000000 --- a/Controller/Phantom.Controller.Services/Agents/AgentStats.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Phantom.Common.Data; - -namespace Phantom.Controller.Services.Agents; - -public sealed record AgentStats( - int RunningInstanceCount, - RamAllocationUnits RunningInstanceMemory -); diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLog.EventTypes.cs b/Controller/Phantom.Controller.Services/Audit/AuditLog.EventTypes.cs deleted file mode 100644 index 3e46965..0000000 --- a/Controller/Phantom.Controller.Services/Audit/AuditLog.EventTypes.cs +++ /dev/null @@ -1,70 +0,0 @@ -using Phantom.Controller.Database.Entities; -using Phantom.Controller.Database.Enums; - -namespace Phantom.Controller.Services.Audit; - -public sealed partial class AuditLog { - public Task AddAdministratorUserCreatedEvent(UserEntity administratorUser) { - return AddItem(AuditLogEventType.AdministratorUserCreated, administratorUser.UserGuid.ToString()); - } - - public Task AddAdministratorUserModifiedEvent(UserEntity administratorUser) { - return AddItem(AuditLogEventType.AdministratorUserModified, administratorUser.UserGuid.ToString()); - } - - public void AddUserLoggedInEvent(UserEntity user) { - AddItem(user.UserGuid, AuditLogEventType.UserLoggedIn, user.UserGuid.ToString()); - } - - public void AddUserLoggedOutEvent(Guid userGuid) { - AddItem(userGuid, AuditLogEventType.UserLoggedOut, userGuid.ToString()); - } - - public Task AddUserCreatedEvent(UserEntity user) { - return AddItem(AuditLogEventType.UserCreated, user.UserGuid.ToString()); - } - - public Task AddUserRolesChangedEvent(UserEntity user, List<string> addedToRoles, List<string> removedFromRoles) { - var extra = new Dictionary<string, object?>(); - - if (addedToRoles.Count > 0) { - extra["addedToRoles"] = addedToRoles; - } - - if (removedFromRoles.Count > 0) { - extra["removedFromRoles"] = removedFromRoles; - } - - return AddItem(AuditLogEventType.UserRolesChanged, user.UserGuid.ToString(), extra); - } - - public Task AddUserDeletedEvent(UserEntity user) { - return AddItem(AuditLogEventType.UserDeleted, user.UserGuid.ToString(), new Dictionary<string, object?> { - { "username", user.Name } - }); - } - - public Task AddInstanceCreatedEvent(Guid instanceGuid) { - return AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); - } - - public Task AddInstanceEditedEvent(Guid instanceGuid) { - return AddItem(AuditLogEventType.InstanceEdited, instanceGuid.ToString()); - } - - public Task AddInstanceLaunchedEvent(Guid instanceGuid) { - return AddItem(AuditLogEventType.InstanceLaunched, instanceGuid.ToString()); - } - - public Task AddInstanceCommandExecutedEvent(Guid instanceGuid, string command) { - return AddItem(AuditLogEventType.InstanceCommandExecuted, instanceGuid.ToString(), new Dictionary<string, object?> { - { "command", command } - }); - } - - public Task AddInstanceStoppedEvent(Guid instanceGuid, int stopInSeconds) { - return AddItem(AuditLogEventType.InstanceStopped, instanceGuid.ToString(), new Dictionary<string, object?> { - { "stop_in_seconds", stopInSeconds.ToString() } - }); - } -} diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLog.cs b/Controller/Phantom.Controller.Services/Audit/AuditLog.cs deleted file mode 100644 index 5b98386..0000000 --- a/Controller/Phantom.Controller.Services/Audit/AuditLog.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Phantom.Controller.Database; -using Phantom.Controller.Database.Entities; -using Phantom.Controller.Database.Enums; -using Phantom.Utils.Tasks; - -namespace Phantom.Controller.Services.Audit; - -public sealed partial class AuditLog { - private readonly IDatabaseProvider databaseProvider; - private readonly TaskManager taskManager; - private readonly CancellationToken cancellationToken; - - public AuditLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { - this.databaseProvider = databaseProvider; - this.taskManager = taskManager; - this.cancellationToken = cancellationToken; - } - - private Task<Guid?> GetCurrentAuthenticatedUserId() { - return Task.FromResult<Guid?>(null); // TODO - } - - private async Task AddEntityToDatabase(AuditLogEntity logEntity) { - 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) { - var logEntity = new AuditLogEntity(userGuid, eventType, subjectId, extra); - taskManager.Run("Store audit log item to database", () => AddEntityToDatabase(logEntity)); - } - - private async Task AddItem(AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { - AddItem(await GetCurrentAuthenticatedUserId(), eventType, subjectId, extra); - } - - public async Task<AuditLogItem[]> GetItems(int count, CancellationToken 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); - } -} diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLogItem.cs b/Controller/Phantom.Controller.Services/Audit/AuditLogItem.cs deleted file mode 100644 index f38c97a..0000000 --- a/Controller/Phantom.Controller.Services/Audit/AuditLogItem.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Text.Json; -using Phantom.Controller.Database.Enums; - -namespace Phantom.Controller.Services.Audit; - -public sealed record AuditLogItem(DateTime UtcTime, Guid? UserGuid, string? UserName, AuditLogEventType EventType, AuditLogSubjectType SubjectType, string? SubjectId, JsonDocument? Data); diff --git a/Controller/Phantom.Controller.Services/ControllerServices.cs b/Controller/Phantom.Controller.Services/ControllerServices.cs index 20e1d62..d228115 100644 --- a/Controller/Phantom.Controller.Services/ControllerServices.cs +++ b/Controller/Phantom.Controller.Services/ControllerServices.cs @@ -1,4 +1,4 @@ -using Phantom.Common.Data.Agent; +using Phantom.Common.Data; using Phantom.Common.Logging; using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Web; @@ -10,8 +10,6 @@ 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; @@ -19,50 +17,58 @@ 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 EventLogManager EventLogManager { get; } + private UserManager UserManager { get; } private RoleManager RoleManager { get; } - private UserRoleManager UserRoleManager { get; } private PermissionManager PermissionManager { get; } - private readonly IDatabaseProvider databaseProvider; + private UserRoleManager UserRoleManager { get; } + private UserLoginManager UserLoginManager { get; } + private AuditLogManager AuditLogManager { get; } + + private readonly IDbContextProvider dbProvider; + private readonly AuthToken webAuthToken; private readonly CancellationToken cancellationToken; - public ControllerServices(IDatabaseProvider databaseProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) { + public ControllerServices(IDbContextProvider dbProvider, AuthToken agentAuthToken, AuthToken webAuthToken, CancellationToken shutdownCancellationToken) { this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>()); this.MinecraftVersions = new MinecraftVersions(); - this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken); + this.AgentManager = new AgentManager(agentAuthToken, dbProvider, TaskManager, shutdownCancellationToken); this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager(); - this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken); - this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken); + this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, dbProvider, 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.UserManager = new UserManager(dbProvider); + this.RoleManager = new RoleManager(dbProvider); + this.PermissionManager = new PermissionManager(dbProvider); + + this.UserRoleManager = new UserRoleManager(dbProvider); + this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager); + this.AuditLogManager = new AuditLogManager(dbProvider); + this.EventLogManager = new EventLogManager(dbProvider, TaskManager, shutdownCancellationToken); - this.databaseProvider = databaseProvider; + this.dbProvider = dbProvider; + this.webAuthToken = webAuthToken; this.cancellationToken = shutdownCancellationToken; } public AgentMessageListener CreateAgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection) { - return new AgentMessageListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLog, cancellationToken); + return new AgentMessageListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLogManager, cancellationToken); } public WebMessageListener CreateWebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) { - return new WebMessageListener(connection); + return new WebMessageListener(connection, webAuthToken, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, MinecraftVersions, EventLogManager, TaskManager); } public async Task Initialize() { - await DatabaseMigrator.Run(databaseProvider, cancellationToken); + await DatabaseMigrator.Run(dbProvider, cancellationToken); await PermissionManager.Initialize(); await RoleManager.Initialize(); await AgentManager.Initialize(); diff --git a/Controller/Phantom.Controller.Services/Events/EventLog.cs b/Controller/Phantom.Controller.Services/Events/EventLog.cs deleted file mode 100644 index f2e86b9..0000000 --- a/Controller/Phantom.Controller.Services/Events/EventLog.cs +++ /dev/null @@ -1,43 +0,0 @@ -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 IDatabaseProvider databaseProvider; - private readonly TaskManager taskManager; - private readonly CancellationToken cancellationToken; - - public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { - this.databaseProvider = databaseProvider; - this.taskManager = taskManager; - this.cancellationToken = cancellationToken; - } - - private async Task AddEntityToDatabase(EventLogEntity logEntity) { - 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) { - var logEntity = new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra); - taskManager.Run("Store event log item to database", () => AddEntityToDatabase(logEntity)); - } - - 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); - } -} diff --git a/Controller/Phantom.Controller.Services/Events/EventLogItem.cs b/Controller/Phantom.Controller.Services/Events/EventLogItem.cs deleted file mode 100644 index 7e9da73..0000000 --- a/Controller/Phantom.Controller.Services/Events/EventLogItem.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Text.Json; -using Phantom.Controller.Database.Enums; - -namespace Phantom.Controller.Services.Events; - -public sealed record EventLogItem(DateTime UtcTime, Guid? AgentGuid, EventLogEventType EventType, EventLogSubjectType SubjectType, string SubjectId, JsonDocument? Data); diff --git a/Controller/Phantom.Controller.Services/Events/EventLog.InstanceEventVisitor.cs b/Controller/Phantom.Controller.Services/Events/EventLogManager.InstanceEventVisitor.cs similarity index 64% rename from Controller/Phantom.Controller.Services/Events/EventLog.InstanceEventVisitor.cs rename to Controller/Phantom.Controller.Services/Events/EventLogManager.InstanceEventVisitor.cs index 915aea8..790a167 100644 --- a/Controller/Phantom.Controller.Services/Events/EventLog.InstanceEventVisitor.cs +++ b/Controller/Phantom.Controller.Services/Events/EventLogManager.InstanceEventVisitor.cs @@ -1,23 +1,23 @@ using Phantom.Common.Data.Backups; using Phantom.Common.Data.Instance; -using Phantom.Controller.Database.Enums; +using Phantom.Common.Data.Web.EventLog; namespace Phantom.Controller.Services.Events; -public sealed partial class EventLog { +sealed partial class EventLogManager { internal IInstanceEventVisitor CreateInstanceEventVisitor(Guid eventGuid, DateTime utcTime, Guid agentGuid, Guid instanceGuid) { return new InstanceEventVisitor(this, utcTime, eventGuid, agentGuid, instanceGuid); } private sealed class InstanceEventVisitor : IInstanceEventVisitor { - private readonly EventLog eventLog; + private readonly EventLogManager eventLogManager; private readonly Guid eventGuid; private readonly DateTime utcTime; private readonly Guid agentGuid; private readonly Guid instanceGuid; - public InstanceEventVisitor(EventLog eventLog, DateTime utcTime, Guid eventGuid, Guid agentGuid, Guid instanceGuid) { - this.eventLog = eventLog; + public InstanceEventVisitor(EventLogManager eventLogManager, DateTime utcTime, Guid eventGuid, Guid agentGuid, Guid instanceGuid) { + this.eventLogManager = eventLogManager; this.eventGuid = eventGuid; this.utcTime = utcTime; this.agentGuid = agentGuid; @@ -25,21 +25,21 @@ public sealed partial class EventLog { } public void OnLaunchSucceeded(InstanceLaunchSuccededEvent e) { - eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchSucceded, instanceGuid.ToString()); + eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchSucceded, instanceGuid.ToString()); } public void OnLaunchFailed(InstanceLaunchFailedEvent e) { - eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchFailed, instanceGuid.ToString(), new Dictionary<string, object?> { + eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceLaunchFailed, instanceGuid.ToString(), new Dictionary<string, object?> { { "reason", e.Reason.ToString() } }); } public void OnCrashed(InstanceCrashedEvent e) { - eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceCrashed, instanceGuid.ToString()); + eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceCrashed, instanceGuid.ToString()); } public void OnStopped(InstanceStoppedEvent e) { - eventLog.AddItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceStopped, instanceGuid.ToString()); + eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, EventLogEventType.InstanceStopped, instanceGuid.ToString()); } public void OnBackupCompleted(InstanceBackupCompletedEvent e) { @@ -59,7 +59,7 @@ public sealed partial class EventLog { dictionary["warnings"] = e.Warnings.ListFlags().Select(static warning => warning.ToString()).ToArray(); } - eventLog.AddItem(eventGuid, utcTime, agentGuid, eventType, instanceGuid.ToString(), dictionary.Count == 0 ? null : dictionary); + eventLogManager.EnqueueItem(eventGuid, utcTime, agentGuid, eventType, instanceGuid.ToString(), dictionary.Count == 0 ? null : dictionary); } } } diff --git a/Controller/Phantom.Controller.Services/Events/EventLogManager.cs b/Controller/Phantom.Controller.Services/Events/EventLogManager.cs new file mode 100644 index 0000000..a6d74ee --- /dev/null +++ b/Controller/Phantom.Controller.Services/Events/EventLogManager.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.EventLog; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Repositories; +using Phantom.Utils.Tasks; + +namespace Phantom.Controller.Services.Events; + +sealed partial class EventLogManager { + private readonly IDbContextProvider dbProvider; + private readonly TaskManager taskManager; + private readonly CancellationToken cancellationToken; + + public EventLogManager(IDbContextProvider dbProvider, TaskManager taskManager, CancellationToken cancellationToken) { + this.dbProvider = dbProvider; + this.taskManager = taskManager; + this.cancellationToken = cancellationToken; + } + + public void EnqueueItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { + taskManager.Run("Store event log item to database", () => AddItem(eventGuid, utcTime, agentGuid, eventType, subjectId, extra)); + } + + public async Task AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { + await using var db = dbProvider.Lazy(); + new EventLogRepository(db).AddItem(eventGuid, utcTime, agentGuid, eventType, subjectId, extra); + await db.Ctx.SaveChangesAsync(cancellationToken); + } + + public async Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count) { + await using var db = dbProvider.Lazy(); + return await new EventLogRepository(db).GetMostRecentItems(count, cancellationToken); + } +} diff --git a/Controller/Phantom.Controller.Services/Instances/AddOrEditInstanceResult.cs b/Controller/Phantom.Controller.Services/Instances/AddOrEditInstanceResult.cs deleted file mode 100644 index d11da1c..0000000 --- a/Controller/Phantom.Controller.Services/Instances/AddOrEditInstanceResult.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Phantom.Controller.Services.Instances; - -public enum AddOrEditInstanceResult : byte { - UnknownError, - Success, - InstanceNameMustNotBeEmpty, - InstanceMemoryMustNotBeZero, - MinecraftVersionDownloadInfoNotFound, - AgentNotFound -} - -public static class AddOrEditInstanceResultExtensions { - public static string ToSentence(this AddOrEditInstanceResult reason) { - return reason switch { - AddOrEditInstanceResult.Success => "Success.", - AddOrEditInstanceResult.InstanceNameMustNotBeEmpty => "Instance name must not be empty.", - AddOrEditInstanceResult.InstanceMemoryMustNotBeZero => "Memory must not be 0 MB.", - AddOrEditInstanceResult.MinecraftVersionDownloadInfoNotFound => "Could not find download information for the selected Minecraft version.", - AddOrEditInstanceResult.AgentNotFound => "Agent not found.", - _ => "Unknown error." - }; - } -} diff --git a/Controller/Phantom.Controller.Services/Instances/Instance.cs b/Controller/Phantom.Controller.Services/Instances/Instance.cs deleted file mode 100644 index 687e893..0000000 --- a/Controller/Phantom.Controller.Services/Instances/Instance.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Phantom.Common.Data.Instance; - -namespace Phantom.Controller.Services.Instances; - -public sealed record Instance( - InstanceConfiguration Configuration, - IInstanceStatus Status, - bool LaunchAutomatically -) { - internal Instance(InstanceConfiguration configuration, bool launchAutomatically = false) : this(configuration, InstanceStatus.Offline, launchAutomatically) {} -} diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs b/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs index d50b3af..3832039 100644 --- a/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs +++ b/Controller/Phantom.Controller.Services/Instances/InstanceLogManager.cs @@ -1,44 +1,13 @@ -using System.Collections.Concurrent; using System.Collections.Immutable; -using Phantom.Common.Logging; -using Phantom.Utils.Collections; -using Phantom.Utils.Events; -using ILogger = Serilog.ILogger; namespace Phantom.Controller.Services.Instances; -public sealed class InstanceLogManager { - private const int RetainedLines = 1000; +sealed class InstanceLogManager { + public sealed record Event(Guid InstanceGuid, ImmutableArray<string> Lines); - private readonly ConcurrentDictionary<Guid, ObservableInstanceLogs> logsByInstanceGuid = new (); - - private ObservableInstanceLogs GetInstanceLogs(Guid instanceGuid) { - return logsByInstanceGuid.GetOrAdd(instanceGuid, static _ => new ObservableInstanceLogs(PhantomLogger.Create<InstanceManager, ObservableInstanceLogs>())); - } + public event EventHandler<Event>? LogsReceived; - internal void AddLines(Guid instanceGuid, ImmutableArray<string> lines) { - GetInstanceLogs(instanceGuid).Add(lines); - } - - public EventSubscribers<RingBuffer<string>> GetSubs(Guid instanceGuid) { - return GetInstanceLogs(instanceGuid).Subs; - } - - private sealed class ObservableInstanceLogs : ObservableState<RingBuffer<string>> { - private readonly RingBuffer<string> log = new (RetainedLines); - - public ObservableInstanceLogs(ILogger logger) : base(logger) {} - - public void Add(ImmutableArray<string> lines) { - foreach (var line in lines) { - log.Add(InstanceLogHtmlFilters.Process(line)); - } - - Update(); - } - - protected override RingBuffer<string> GetData() { - return log; - } + internal void ReceiveLines(Guid instanceGuid, ImmutableArray<string> lines) { + LogsReceived?.Invoke(this, new Event(instanceGuid, lines)); } } diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs index cb042be..b4fb771 100644 --- a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs +++ b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs @@ -4,20 +4,23 @@ using Phantom.Common.Data; using Phantom.Common.Data.Instance; using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Replies; +using Phantom.Common.Data.Web.Instance; +using Phantom.Common.Data.Web.Minecraft; using Phantom.Common.Logging; using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Controller.Database; using Phantom.Controller.Database.Entities; +using Phantom.Controller.Database.Repositories; using Phantom.Controller.Minecraft; using Phantom.Controller.Services.Agents; using Phantom.Utils.Collections; using Phantom.Utils.Events; -using ILogger = Serilog.ILogger; +using Serilog; namespace Phantom.Controller.Services.Instances; -public sealed class InstanceManager { +sealed class InstanceManager { private static readonly ILogger Logger = PhantomLogger.Create<InstanceManager>(); private readonly ObservableInstances instances = new (PhantomLogger.Create<InstanceManager, ObservableInstances>()); @@ -26,20 +29,20 @@ public sealed class InstanceManager { private readonly AgentManager agentManager; private readonly MinecraftVersions minecraftVersions; - private readonly IDatabaseProvider databaseProvider; + private readonly IDbContextProvider dbProvider; private readonly CancellationToken cancellationToken; private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1); - public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) { + public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) { this.agentManager = agentManager; this.minecraftVersions = minecraftVersions; - this.databaseProvider = databaseProvider; + this.dbProvider = dbProvider; this.cancellationToken = cancellationToken; } public async Task Initialize() { - await using var ctx = databaseProvider.Provide(); + await using var ctx = dbProvider.Eager(); await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) { var configuration = new InstanceConfiguration( entity.AgentGuid, @@ -54,53 +57,53 @@ public sealed class InstanceManager { JvmArgumentsHelper.Split(entity.JvmArguments) ); - var instance = new Instance(configuration, entity.LaunchAutomatically); + var instance = Instance.Offline(configuration, entity.LaunchAutomatically); instances.ByGuid[instance.Configuration.InstanceGuid] = instance; } } [SuppressMessage("ReSharper", "ConvertIfStatementToConditionalTernaryExpression")] - public async Task<InstanceActionResult<AddOrEditInstanceResult>> AddOrEditInstance(InstanceConfiguration configuration) { + public async Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid auditLogUserGuid, InstanceConfiguration configuration) { var agent = agentManager.GetAgent(configuration.AgentGuid); if (agent == null) { - return InstanceActionResult.Concrete(AddOrEditInstanceResult.AgentNotFound); + return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.AgentNotFound); } if (string.IsNullOrWhiteSpace(configuration.InstanceName)) { - return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceNameMustNotBeEmpty); + return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty); } if (configuration.MemoryAllocation <= RamAllocationUnits.Zero) { - return InstanceActionResult.Concrete(AddOrEditInstanceResult.InstanceMemoryMustNotBeZero); + return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero); } var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(configuration.MinecraftVersion, cancellationToken); if (serverExecutableInfo == null) { - return InstanceActionResult.Concrete(AddOrEditInstanceResult.MinecraftVersionDownloadInfoNotFound); + return InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound); } - InstanceActionResult<AddOrEditInstanceResult> result; + InstanceActionResult<CreateOrUpdateInstanceResult> result; bool isNewInstance; await modifyInstancesSemaphore.WaitAsync(cancellationToken); try { isNewInstance = !instances.ByGuid.TryReplace(configuration.InstanceGuid, instance => instance with { Configuration = configuration }); if (isNewInstance) { - instances.ByGuid.TryAdd(configuration.InstanceGuid, new Instance(configuration)); + instances.ByGuid.TryAdd(configuration.InstanceGuid, Instance.Offline(configuration)); } var message = new ConfigureInstanceMessage(configuration, new InstanceLaunchProperties(serverExecutableInfo)); var reply = await agentManager.SendMessage<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(configuration.AgentGuid, message, TimeSpan.FromSeconds(10)); result = reply.DidNotReplyIfNull().Map(static result => result switch { - ConfigureInstanceResult.Success => AddOrEditInstanceResult.Success, - _ => AddOrEditInstanceResult.UnknownError + ConfigureInstanceResult.Success => CreateOrUpdateInstanceResult.Success, + _ => CreateOrUpdateInstanceResult.UnknownError }); - if (result.Is(AddOrEditInstanceResult.Success)) { - await using var ctx = databaseProvider.Provide(); - InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid); - + if (result.Is(CreateOrUpdateInstanceResult.Success)) { + await using var db = dbProvider.Lazy(); + + InstanceEntity entity = db.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid); entity.AgentGuid = configuration.AgentGuid; entity.InstanceName = configuration.InstanceName; entity.ServerPort = configuration.ServerPort; @@ -110,8 +113,16 @@ public sealed class InstanceManager { entity.MemoryAllocation = configuration.MemoryAllocation; entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid; entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments); + + var auditLogWriter = new AuditLogRepository(db).Writer(auditLogUserGuid); + if (isNewInstance) { + auditLogWriter.InstanceCreated(configuration.InstanceGuid); + } + else { + auditLogWriter.InstanceEdited(configuration.InstanceGuid); + } - await ctx.SaveChangesAsync(cancellationToken); + await db.Ctx.SaveChangesAsync(cancellationToken); } else if (isNewInstance) { instances.ByGuid.Remove(configuration.InstanceGuid); @@ -120,7 +131,7 @@ public sealed class InstanceManager { modifyInstancesSemaphore.Release(); } - if (result.Is(AddOrEditInstanceResult.Success)) { + if (result.Is(CreateOrUpdateInstanceResult.Success)) { if (isNewInstance) { Logger.Information("Added instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\".", configuration.InstanceName, configuration.InstanceGuid, agent.Name); } @@ -130,24 +141,16 @@ public sealed class InstanceManager { } else { if (isNewInstance) { - Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence)); + Logger.Information("Failed adding instance \"{InstanceName}\" (GUID {InstanceGuid}) to agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence)); } else { - Logger.Information("Failed editing instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence)); + Logger.Information("Failed editing instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\". {ErrorMessage}", configuration.InstanceName, configuration.InstanceGuid, agent.Name, result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence)); } } return result; } - public ImmutableDictionary<Guid, string> GetInstanceNames() { - return instances.ByGuid.ToImmutable<string>(static instance => instance.Configuration.InstanceName); - } - - public InstanceConfiguration? GetInstanceConfiguration(Guid instanceGuid) { - return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? instance.Configuration : null; - } - internal void SetInstanceState(Guid instanceGuid, IInstanceStatus instanceStatus) { instances.ByGuid.TryReplace(instanceGuid, instance => instance with { Status = instanceStatus }); } @@ -165,42 +168,52 @@ public sealed class InstanceManager { return instances.ByGuid.TryGetValue(instanceGuid, out var instance) ? await SendInstanceActionMessage<TMessage, TReply>(instance, message) : InstanceActionResult.General<TReply>(InstanceActionGeneralResult.InstanceDoesNotExist); } - public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid instanceGuid) { + public async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid auditLogUserGuid, Guid instanceGuid) { var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(instanceGuid, new LaunchInstanceMessage(instanceGuid)); if (result.Is(LaunchInstanceResult.LaunchInitiated)) { - await SetInstanceShouldLaunchAutomatically(instanceGuid, true); + await HandleInstanceManuallyLaunchedOrStopped(instanceGuid, true, auditLogUserGuid, auditLogWriter => auditLogWriter.InstanceLaunched(instanceGuid)); } return result; } - public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid instanceGuid, MinecraftStopStrategy stopStrategy) { + public async Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid auditLogUserGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy) { var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(instanceGuid, new StopInstanceMessage(instanceGuid, stopStrategy)); if (result.Is(StopInstanceResult.StopInitiated)) { - await SetInstanceShouldLaunchAutomatically(instanceGuid, false); + await HandleInstanceManuallyLaunchedOrStopped(instanceGuid, false, auditLogUserGuid, auditLogWriter => auditLogWriter.InstanceStopped(instanceGuid, stopStrategy.Seconds)); } return result; } - private async Task SetInstanceShouldLaunchAutomatically(Guid instanceGuid, bool shouldLaunchAutomatically) { + private async Task HandleInstanceManuallyLaunchedOrStopped(Guid instanceGuid, bool wasLaunched, Guid auditLogUserGuid, Action<AuditLogRepository.ItemWriter> addAuditEvent) { await modifyInstancesSemaphore.WaitAsync(cancellationToken); try { - instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically }); + instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = wasLaunched }); - await using var ctx = databaseProvider.Provide(); - var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken); + await using var db = dbProvider.Lazy(); + var entity = await db.Ctx.Instances.FindAsync(new object[] { instanceGuid }, cancellationToken); if (entity != null) { - entity.LaunchAutomatically = shouldLaunchAutomatically; - await ctx.SaveChangesAsync(cancellationToken); + entity.LaunchAutomatically = wasLaunched; + addAuditEvent(new AuditLogRepository(db).Writer(auditLogUserGuid)); + await db.Ctx.SaveChangesAsync(cancellationToken); } } finally { modifyInstancesSemaphore.Release(); } } - public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) { - return await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command)); + public async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid auditLogUserId, Guid instanceGuid, string command) { + var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(instanceGuid, new SendCommandToInstanceMessage(instanceGuid, command)); + if (result.Is(SendCommandToInstanceResult.Success)) { + await using var db = dbProvider.Lazy(); + var auditLogWriter = new AuditLogRepository(db).Writer(auditLogUserId); + + auditLogWriter.InstanceCommandExecuted(instanceGuid, command); + await db.Ctx.SaveChangesAsync(cancellationToken); + } + + return result; } internal async Task<ImmutableArray<ConfigureInstanceMessage>> GetInstanceConfigurationsForAgent(Guid agentGuid) { diff --git a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj index f227ef4..d400e20 100644 --- a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj +++ b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj @@ -11,6 +11,7 @@ <ItemGroup> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" /> <ProjectReference Include="..\Phantom.Controller.Database\Phantom.Controller.Database.csproj" /> <ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" /> diff --git a/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs index d0b5ec6..9f17a07 100644 --- a/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs +++ b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs @@ -19,18 +19,18 @@ public sealed class AgentMessageListener : IMessageToControllerListener { private readonly AgentJavaRuntimesManager agentJavaRuntimesManager; private readonly InstanceManager instanceManager; private readonly InstanceLogManager instanceLogManager; - private readonly EventLog eventLog; + private readonly EventLogManager eventLogManager; private readonly CancellationToken cancellationToken; private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>(); - internal AgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog, CancellationToken cancellationToken) { + internal AgentMessageListener(RpcConnectionToClient<IMessageToAgentListener> connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager, CancellationToken cancellationToken) { this.connection = connection; this.agentManager = agentManager; this.agentJavaRuntimesManager = agentJavaRuntimesManager; this.instanceManager = instanceManager; this.instanceLogManager = instanceLogManager; - this.eventLog = eventLog; + this.eventLogManager = eventLogManager; this.cancellationToken = cancellationToken; } @@ -83,12 +83,12 @@ public sealed class AgentMessageListener : IMessageToControllerListener { } public async Task<NoReply> HandleReportInstanceEvent(ReportInstanceEventMessage message) { - message.Event.Accept(eventLog.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, await WaitForAgentGuid(), message.InstanceGuid)); + message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, await WaitForAgentGuid(), message.InstanceGuid)); return NoReply.Instance; } public Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message) { - instanceLogManager.AddLines(message.InstanceGuid, message.Lines); + instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines); return Task.FromResult(NoReply.Instance); } diff --git a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs index 0b109cf..510a32b 100644 --- a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs +++ b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs @@ -1,15 +1,199 @@ -using Phantom.Common.Messages.Web; +using System.Collections.Immutable; +using Phantom.Common.Data; +using Phantom.Common.Data.Java; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; +using Phantom.Common.Data.Web.Agent; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Common.Data.Web.EventLog; +using Phantom.Common.Data.Web.Instance; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Logging; +using Phantom.Common.Messages.Web; using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Common.Messages.Web.ToWeb; +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.Users; using Phantom.Utils.Rpc.Message; +using Phantom.Utils.Tasks; +using Serilog; namespace Phantom.Controller.Services.Rpc; public sealed class WebMessageListener : IMessageToControllerListener { - private readonly RpcConnectionToClient<IMessageToWebListener> connection; + private static readonly ILogger Logger = PhantomLogger.Create<WebMessageListener>(); - internal WebMessageListener(RpcConnectionToClient<IMessageToWebListener> connection) { + private readonly RpcConnectionToClient<IMessageToWebListener> connection; + private readonly AuthToken authToken; + private readonly UserManager userManager; + private readonly RoleManager roleManager; + private readonly UserRoleManager userRoleManager; + private readonly UserLoginManager userLoginManager; + private readonly AuditLogManager auditLogManager; + private readonly AgentManager agentManager; + private readonly AgentJavaRuntimesManager agentJavaRuntimesManager; + private readonly InstanceManager instanceManager; + private readonly InstanceLogManager instanceLogManager; + private readonly MinecraftVersions minecraftVersions; + private readonly EventLogManager eventLogManager; + private readonly TaskManager taskManager; + + internal WebMessageListener( + RpcConnectionToClient<IMessageToWebListener> connection, + AuthToken authToken, + UserManager userManager, + RoleManager roleManager, + UserRoleManager userRoleManager, + UserLoginManager userLoginManager, + AuditLogManager auditLogManager, + AgentManager agentManager, + AgentJavaRuntimesManager agentJavaRuntimesManager, + InstanceManager instanceManager, + InstanceLogManager instanceLogManager, + MinecraftVersions minecraftVersions, + EventLogManager eventLogManager, + TaskManager taskManager + ) { this.connection = connection; + this.authToken = authToken; + this.userManager = userManager; + this.roleManager = roleManager; + this.userRoleManager = userRoleManager; + this.userLoginManager = userLoginManager; + this.auditLogManager = auditLogManager; + this.agentManager = agentManager; + this.agentJavaRuntimesManager = agentJavaRuntimesManager; + this.instanceManager = instanceManager; + this.instanceLogManager = instanceLogManager; + this.minecraftVersions = minecraftVersions; + this.eventLogManager = eventLogManager; + this.taskManager = taskManager; + } + + private void OnConnectionReady() { + lock (this) { + agentManager.AgentsChanged.Subscribe(this, HandleAgentsChanged); + instanceManager.InstancesChanged.Subscribe(this, HandleInstancesChanged); + instanceLogManager.LogsReceived += HandleInstanceLogsReceived; + } + } + + private void OnConnectionClosed() { + lock (this) { + agentManager.AgentsChanged.Unsubscribe(this); + instanceManager.InstancesChanged.Unsubscribe(this); + instanceLogManager.LogsReceived -= HandleInstanceLogsReceived; + } + } + + private void HandleAgentsChanged(ImmutableArray<Agent> agents) { + var message = new RefreshAgentsMessage(agents.Select(static agent => new AgentWithStats(agent.Guid, agent.Name, agent.ProtocolVersion, agent.BuildVersion, agent.MaxInstances, agent.MaxMemory, agent.AllowedServerPorts, agent.AllowedRconPorts, agent.Stats, agent.LastPing, agent.IsOnline)).ToImmutableArray()); + taskManager.Run("Send agents to web", () => connection.Send(message)); + } + + private void HandleInstancesChanged(ImmutableDictionary<Guid, Instance> instances) { + var message = new RefreshInstancesMessage(instances.Values.ToImmutableArray()); + taskManager.Run("Send instances to web", () => connection.Send(message)); + } + + private void HandleInstanceLogsReceived(object? sender, InstanceLogManager.Event e) { + taskManager.Run("Send instance logs to web", () => connection.Send(new InstanceOutputMessage(e.InstanceGuid, e.Lines))); + } + + public async Task<NoReply> HandleRegisterWeb(RegisterWebMessage message) { + if (authToken.FixedTimeEquals(message.AuthToken)) { + Logger.Information("Web authorized successfully."); + connection.IsAuthorized = true; + await connection.Send(new RegisterWebResultMessage(true)); + } + else { + Logger.Warning("Web failed to authorize, invalid token."); + await connection.Send(new RegisterWebResultMessage(false)); + } + + if (!connection.IsClosed) { + OnConnectionReady(); + } + + return NoReply.Instance; + } + + public Task<NoReply> HandleUnregisterWeb(UnregisterWebMessage message) { + if (!connection.IsClosed) { + connection.Close(); + OnConnectionClosed(); + } + + return Task.FromResult(NoReply.Instance); + } + + public Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) { + return userManager.CreateOrUpdateAdministrator(message.Username, message.Password); + } + + public Task<CreateUserResult> HandleCreateUser(CreateUserMessage message) { + return userManager.Create(message.LoggedInUserGuid, message.Username, message.Password); + } + + public Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) { + return userManager.GetAll(); + } + + public Task<ImmutableArray<RoleInfo>> HandleGetRoles(GetRolesMessage message) { + return roleManager.GetAll(); + } + + public Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> HandleGetUserRoles(GetUserRolesMessage message) { + return userRoleManager.GetUserRoles(message.UserGuids); + } + + public Task<ChangeUserRolesResult> HandleChangeUserRoles(ChangeUserRolesMessage message) { + return userRoleManager.ChangeUserRoles(message.LoggedInUserGuid, message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids); + } + + public Task<DeleteUserResult> HandleDeleteUser(DeleteUserMessage message) { + return userManager.DeleteByGuid(message.LoggedInUserGuid, message.SubjectUserGuid); + } + + public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) { + return instanceManager.CreateOrUpdateInstance(message.LoggedInUserGuid, message.Configuration); + } + + public Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) { + return instanceManager.LaunchInstance(message.LoggedInUserGuid, message.InstanceGuid); + } + + public Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) { + return instanceManager.StopInstance(message.LoggedInUserGuid, message.InstanceGuid, message.StopStrategy); + } + + public Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { + return instanceManager.SendCommand(message.LoggedInUserGuid, message.InstanceGuid, message.Command); + } + + public Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) { + return minecraftVersions.GetVersions(CancellationToken.None); + } + + public Task<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) { + return Task.FromResult(agentJavaRuntimesManager.All); + } + + public Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message) { + return auditLogManager.GetMostRecentItems(message.Count); + } + + public Task<ImmutableArray<EventLogItem>> HandleGetEventLog(GetEventLogMessage message) { + return eventLogManager.GetMostRecentItems(message.Count); + } + + public Task<LogInSuccess?> HandleLogIn(LogInMessage message) { + return userLoginManager.LogIn(message.Username, message.Password); } public Task<NoReply> HandleReply(ReplyMessage message) { diff --git a/Controller/Phantom.Controller.Services/Users/AddUserError.cs b/Controller/Phantom.Controller.Services/Users/AddUserError.cs deleted file mode 100644 index a40d6af..0000000 --- a/Controller/Phantom.Controller.Services/Users/AddUserError.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Immutable; - -namespace Phantom.Controller.Services.Users; - -public abstract record AddUserError { - private AddUserError() {} - - public sealed record NameIsEmpty : AddUserError; - - public sealed record NameIsTooLong(int MaximumLength) : AddUserError; - - public sealed record NameAlreadyExists : AddUserError; - - public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError; - - public sealed record UnknownError : AddUserError; -} diff --git a/Controller/Phantom.Controller.Services/Users/AuditLogManager.cs b/Controller/Phantom.Controller.Services/Users/AuditLogManager.cs new file mode 100644 index 0000000..1511802 --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/AuditLogManager.cs @@ -0,0 +1,19 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Repositories; + +namespace Phantom.Controller.Services.Users; + +sealed class AuditLogManager { + private readonly IDbContextProvider dbProvider; + + public AuditLogManager(IDbContextProvider dbProvider) { + this.dbProvider = dbProvider; + } + + public async Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count) { + await using var db = dbProvider.Lazy(); + return await new AuditLogRepository(db).GetMostRecentItems(count, CancellationToken.None); + } +} diff --git a/Controller/Phantom.Controller.Services/Users/PasswordRequirementViolation.cs b/Controller/Phantom.Controller.Services/Users/PasswordRequirementViolation.cs deleted file mode 100644 index c29dff3..0000000 --- a/Controller/Phantom.Controller.Services/Users/PasswordRequirementViolation.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Phantom.Controller.Services.Users; - -public abstract record PasswordRequirementViolation { - private PasswordRequirementViolation() {} - - public sealed record TooShort(int MinimumLength) : PasswordRequirementViolation; - - public sealed record LowercaseLetterRequired : PasswordRequirementViolation; - - public sealed record UppercaseLetterRequired : PasswordRequirementViolation; - - public sealed record DigitRequired : PasswordRequirementViolation; -} diff --git a/Controller/Phantom.Controller.Services/Users/PermissionManager.cs b/Controller/Phantom.Controller.Services/Users/PermissionManager.cs new file mode 100644 index 0000000..6e00a4e --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/PermissionManager.cs @@ -0,0 +1,70 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Logging; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; +using Serilog; + +namespace Phantom.Controller.Services.Users; + +sealed class PermissionManager { + private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>(); + + private readonly IDbContextProvider dbProvider; + + public PermissionManager(IDbContextProvider dbProvider) { + this.dbProvider = dbProvider; + } + + public async Task Initialize() { + Logger.Information("Adding default permissions to database."); + + await using var ctx = dbProvider.Eager(); + + 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(); + } + } + + public async Task<PermissionSet> FetchPermissionsForAllUsers(Guid userId) { + await using var ctx = dbProvider.Eager(); + + 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 PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync()); + } + + public async Task<PermissionSet> FetchPermissionsForUserId(Guid userId) { + await using var ctx = dbProvider.Eager(); + + 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 PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync()); + } + + public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) { + return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray(); + } +} diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs b/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs deleted file mode 100644 index 9d4ad14..0000000 --- a/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Immutable; - -namespace Phantom.Controller.Services.Users.Permissions; - -public sealed class IdentityPermissions { - internal static IdentityPermissions None { get; } = new (); - - private readonly ImmutableHashSet<string> permissionIds; - - internal IdentityPermissions(IQueryable<string> permissionIdsQuery) { - this.permissionIds = permissionIdsQuery.ToImmutableHashSet(); - } - - private IdentityPermissions() { - this.permissionIds = ImmutableHashSet<string>.Empty; - } - - public bool Check(Permission? permission) { - while (permission != null) { - if (!permissionIds.Contains(permission.Id)) { - return false; - } - - permission = permission.Parent; - } - - return true; - } -} diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs b/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs deleted file mode 100644 index f8560d0..0000000 --- a/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs +++ /dev/null @@ -1,68 +0,0 @@ -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); - } -} diff --git a/Controller/Phantom.Controller.Services/Users/Roles/Role.cs b/Controller/Phantom.Controller.Services/Users/Role.cs similarity index 89% rename from Controller/Phantom.Controller.Services/Users/Roles/Role.cs rename to Controller/Phantom.Controller.Services/Users/Role.cs index eb2bbd9..bd84a73 100644 --- a/Controller/Phantom.Controller.Services/Users/Roles/Role.cs +++ b/Controller/Phantom.Controller.Services/Users/Role.cs @@ -1,7 +1,7 @@ using System.Collections.Immutable; -using Phantom.Controller.Services.Users.Permissions; +using Phantom.Common.Data.Web.Users; -namespace Phantom.Controller.Services.Users.Roles; +namespace Phantom.Controller.Services.Users; public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) { private static readonly List<Role> AllRoles = new (); diff --git a/Controller/Phantom.Controller.Services/Users/RoleManager.cs b/Controller/Phantom.Controller.Services/Users/RoleManager.cs new file mode 100644 index 0000000..fc09c46 --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/RoleManager.cs @@ -0,0 +1,62 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Logging; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Entities; +using Phantom.Controller.Database.Repositories; +using Phantom.Utils.Collections; +using Serilog; + +namespace Phantom.Controller.Services.Users; + +sealed class RoleManager { + private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>(); + + private readonly IDbContextProvider dbProvider; + + public RoleManager(IDbContextProvider dbProvider) { + this.dbProvider = dbProvider; + } + + internal async Task Initialize() { + Logger.Information("Adding default roles to database."); + + await using var ctx = dbProvider.Eager(); + + 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<ImmutableArray<RoleInfo>> GetAll() { + await using var db = dbProvider.Lazy(); + var roleRepository = new RoleRepository(db); + + var allRoles = await roleRepository.GetAll(); + return allRoles.Select(static role => role.ToRoleInfo()).ToImmutableArray(); + } +} diff --git a/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs b/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs deleted file mode 100644 index 39e5aac..0000000 --- a/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs +++ /dev/null @@ -1,99 +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.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); - } -} diff --git a/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs b/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs deleted file mode 100644 index 579fab1..0000000 --- a/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs +++ /dev/null @@ -1,83 +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.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; - } -} diff --git a/Controller/Phantom.Controller.Services/Users/SetUserPasswordError.cs b/Controller/Phantom.Controller.Services/Users/SetUserPasswordError.cs deleted file mode 100644 index afb887b..0000000 --- a/Controller/Phantom.Controller.Services/Users/SetUserPasswordError.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Immutable; - -namespace Phantom.Controller.Services.Users; - -public abstract record SetUserPasswordError { - private SetUserPasswordError() {} - - public sealed record UserNotFound : SetUserPasswordError; - - public sealed record PasswordIsInvalid(ImmutableArray<PasswordRequirementViolation> Violations) : SetUserPasswordError; - - public sealed record UnknownError : SetUserPasswordError; -} diff --git a/Controller/Phantom.Controller.Services/Users/UserLoginManager.cs b/Controller/Phantom.Controller.Services/Users/UserLoginManager.cs new file mode 100644 index 0000000..889949a --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/UserLoginManager.cs @@ -0,0 +1,34 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Security.Cryptography; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Controller.Services.Users; + +sealed class UserLoginManager { + private const int SessionIdBytes = 20; + private readonly ConcurrentDictionary<string, List<ImmutableArray<byte>>> sessionTokensByUsername = new (); + + private readonly UserManager userManager; + private readonly PermissionManager permissionManager; + + public UserLoginManager(UserManager userManager, PermissionManager permissionManager) { + this.userManager = userManager; + this.permissionManager = permissionManager; + } + + public async Task<LogInSuccess?> LogIn(string username, string password) { + var user = await userManager.GetAuthenticated(username, password); + if (user == null) { + return null; + } + + var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes)); + var sessionTokens = sessionTokensByUsername.GetOrAdd(username, static _ => new List<ImmutableArray<byte>>()); + lock (sessionTokens) { + sessionTokens.Add(token); + } + + return new LogInSuccess(user.UserGuid, await permissionManager.FetchPermissionsForUserId(user.UserGuid), token); + } +} diff --git a/Controller/Phantom.Controller.Services/Users/UserManager.cs b/Controller/Phantom.Controller.Services/Users/UserManager.cs index 7866933..1bb22f7 100644 --- a/Controller/Phantom.Controller.Services/Users/UserManager.cs +++ b/Controller/Phantom.Controller.Services/Users/UserManager.cs @@ -1,136 +1,134 @@ using System.Collections.Immutable; -using System.Security.Claims; -using Microsoft.EntityFrameworkCore; +using Phantom.Common.Data.Web.Users; 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; +using Phantom.Controller.Database.Repositories; +using Serilog; namespace Phantom.Controller.Services.Users; -public sealed class UserManager { +sealed class UserManager { private static readonly ILogger Logger = PhantomLogger.Create<UserManager>(); - private const int MaxUserNameLength = 40; + private readonly IDbContextProvider dbProvider; - private readonly IDatabaseProvider databaseProvider; - - public UserManager(IDatabaseProvider databaseProvider) { - this.databaseProvider = databaseProvider; + public UserManager(IDbContextProvider dbProvider) { + this.dbProvider = dbProvider; } - public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) { - if (user.Identity is not { IsAuthenticated: true }) { - return null; - } + public async Task<ImmutableArray<UserInfo>> GetAll() { + await using var db = dbProvider.Lazy(); + var userRepository = new UserRepository(db); - var claim = user.FindFirst(ClaimTypes.NameIdentifier); - if (claim == null) { - return null; - } - - return Guid.TryParse(claim.Value, out var guid) ? guid : null; - } - - public async Task<ImmutableArray<UserEntity>> GetAll() { - await using var ctx = databaseProvider.Provide(); - return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync(); - } - - 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 async Task<UserEntity?> GetByName(string username) { - await using var ctx = databaseProvider.Provide(); - return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); + var allUsers = await userRepository.GetAll(); + return allUsers.Select(static user => user.ToUserInfo()).ToImmutableArray(); } public async Task<UserEntity?> GetAuthenticated(string username, string password) { - await using var ctx = databaseProvider.Provide(); - var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); - return user != null && UserPasswords.Verify(user, password) ? user : null; + await using var db = dbProvider.Lazy(); + var userRepository = new UserRepository(db); + + var user = await userRepository.GetByName(username); + return user != null && UserPasswords.Verify(password, user.PasswordHash) ? user : null; } - public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { - if (string.IsNullOrWhiteSpace(username)) { - return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsEmpty()); - } - else if (username.Length > MaxUserNameLength) { - return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameIsTooLong(MaxUserNameLength)); - } + public async Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministrator(string username, string password) { + await using var db = dbProvider.Lazy(); + var userRepository = new UserRepository(db); + var auditLogWriter = new AuditLogRepository(db).Writer(currentUserGuid: null); - var requirementViolations = UserPasswords.CheckRequirements(password); - if (!requirementViolations.IsEmpty) { - return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations)); - } - - UserEntity newUser; try { - await using var ctx = databaseProvider.Provide(); - - if (await ctx.Users.AnyAsync(user => user.Name == username)) { - return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists()); - } + bool wasCreated; - newUser = new UserEntity(Guid.NewGuid(), username); - UserPasswords.Set(newUser, password); - - 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) { - UserEntity foundUser; - - await using (var ctx = databaseProvider.Provide()) { - var user = await ctx.Users.FindAsync(guid); + var user = await userRepository.GetByName(username); if (user == null) { - return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound()); + var result = await userRepository.CreateUser(username, password); + if (result) { + user = result.Value; + auditLogWriter.AdministratorUserCreated(user); + wasCreated = true; + } + else { + return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.CreationFailed(result.Error); + } } - - foundUser = user; - try { - var requirementViolations = UserPasswords.CheckRequirements(password); - if (!requirementViolations.IsEmpty) { - return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations)); + else { + var result = userRepository.SetUserPassword(user, password); + if (!result) { + return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UpdatingFailed(result.Error); } - 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()); + auditLogWriter.AdministratorUserModified(user); + wasCreated = false; } - } - Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid); - return Result.Ok<SetUserPasswordError>(); + var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid); + if (role == null) { + return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.AddingToRoleFailed(); + } + + await new UserRoleRepository(db).Add(user, role); + await db.Ctx.SaveChangesAsync(); + + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (wasCreated) { + Logger.Information("Created administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid); + } + else { + Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid); + } + + return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.Success(user.ToUserInfo()); + } catch (Exception e) { + Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username); + return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UnknownError(); + } } - public async Task<DeleteUserResult> DeleteByGuid(Guid guid) { - await using var ctx = databaseProvider.Provide(); - var user = await ctx.Users.FindAsync(guid); + public async Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password) { + await using var db = dbProvider.Lazy(); + var userRepository = new UserRepository(db); + var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid); + + try { + var result = await userRepository.CreateUser(username, password); + if (!result) { + return new Common.Data.Web.Users.CreateUserResults.CreationFailed(result.Error); + } + + var user = result.Value; + + auditLogWriter.UserCreated(user); + await db.Ctx.SaveChangesAsync(); + + Logger.Information("Created user \"{Username}\" (GUID {Guid}).", username, user.UserGuid); + return new Common.Data.Web.Users.CreateUserResults.Success(user.ToUserInfo()); + } catch (Exception e) { + Logger.Error(e, "Could not create user \"{Username}\".", username); + return new Common.Data.Web.Users.CreateUserResults.UnknownError(); + } + } + + public async Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid) { + await using var db = dbProvider.Lazy(); + var userRepository = new UserRepository(db); + + var user = await userRepository.GetByGuid(userGuid); if (user == null) { return DeleteUserResult.NotFound; } + var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid); try { - ctx.Users.Remove(user); - await ctx.SaveChangesAsync(); + userRepository.DeleteUser(user); + auditLogWriter.UserDeleted(user); + await db.Ctx.SaveChangesAsync(); + + Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); return DeleteUserResult.Deleted; } catch (Exception e) { - Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); + Logger.Error(e, "Could not delete user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); return DeleteUserResult.Failed; } } diff --git a/Controller/Phantom.Controller.Services/Users/UserPasswords.cs b/Controller/Phantom.Controller.Services/Users/UserPasswords.cs deleted file mode 100644 index 1618395..0000000 --- a/Controller/Phantom.Controller.Services/Users/UserPasswords.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Immutable; -using Phantom.Controller.Database.Entities; - -namespace Phantom.Controller.Services.Users; - -internal static class UserPasswords { - private const int MinimumLength = 16; - - public static ImmutableArray<PasswordRequirementViolation> CheckRequirements(string password) { - var violations = ImmutableArray.CreateBuilder<PasswordRequirementViolation>(); - - if (password.Length < MinimumLength) { - violations.Add(new PasswordRequirementViolation.TooShort(MinimumLength)); - } - - if (!password.Any(char.IsLower)) { - violations.Add(new PasswordRequirementViolation.LowercaseLetterRequired()); - } - - if (!password.Any(char.IsUpper)) { - violations.Add(new PasswordRequirementViolation.UppercaseLetterRequired()); - } - - if (!password.Any(char.IsDigit)) { - violations.Add(new PasswordRequirementViolation.DigitRequired()); - } - - return violations.ToImmutable(); - } - - public static void Set(UserEntity user, string password) { - user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password); - } - - public static bool Verify(UserEntity user, string password) { - return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash); - } -} diff --git a/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs b/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs new file mode 100644 index 0000000..c29f5ee --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Logging; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Repositories; +using Serilog; + +namespace Phantom.Controller.Services.Users; + +sealed class UserRoleManager { + private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>(); + + private readonly IDbContextProvider dbProvider; + + public UserRoleManager(IDbContextProvider dbProvider) { + this.dbProvider = dbProvider; + } + + public async Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetUserRoles(ImmutableHashSet<Guid> userGuids) { + await using var db = dbProvider.Lazy(); + return await new UserRoleRepository(db).GetRoleGuidsByUserGuid(userGuids); + } + + public async Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) { + await using var db = dbProvider.Lazy(); + var userRepository = new UserRepository(db); + + var user = await userRepository.GetByGuid(subjectUserGuid); + if (user == null) { + return new ChangeUserRolesResult(ImmutableHashSet<Guid>.Empty, ImmutableHashSet<Guid>.Empty); + } + + var roleRepository = new RoleRepository(db); + var userRoleRepository = new UserRoleRepository(db); + var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid); + + var rolesByGuid = await roleRepository.GetByGuids(addToRoleGuids.Union(removeFromRoleGuids)); + + var addedToRoleGuids = ImmutableHashSet.CreateBuilder<Guid>(); + var addedToRoleNames = new List<string>(); + + var removedFromRoleGuids = ImmutableHashSet.CreateBuilder<Guid>(); + var removedFromRoleNames = new List<string>(); + + try { + foreach (var roleGuid in addToRoleGuids) { + if (rolesByGuid.TryGetValue(roleGuid, out var role)) { + await userRoleRepository.Add(user, role); + addedToRoleGuids.Add(roleGuid); + addedToRoleNames.Add(role.Name); + } + } + + foreach (var roleGuid in removeFromRoleGuids) { + if (rolesByGuid.TryGetValue(roleGuid, out var role)) { + await userRoleRepository.Remove(user, role); + removedFromRoleGuids.Add(roleGuid); + removedFromRoleNames.Add(role.Name); + } + } + + auditLogWriter.UserRolesChanged(user, addedToRoleNames, removedFromRoleNames); + await db.Ctx.SaveChangesAsync(); + + Logger.Information("Changed roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); + return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable()); + } catch (Exception e) { + Logger.Error(e, "Could not change roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); + return new ChangeUserRolesResult(ImmutableHashSet<Guid>.Empty, ImmutableHashSet<Guid>.Empty); + } + } +} diff --git a/Controller/Phantom.Controller/ConnectionKeyData.cs b/Controller/Phantom.Controller/ConnectionKeyData.cs index c9d3f3e..124b322 100644 --- a/Controller/Phantom.Controller/ConnectionKeyData.cs +++ b/Controller/Phantom.Controller/ConnectionKeyData.cs @@ -1,5 +1,5 @@ using NetMQ; -using Phantom.Common.Data.Agent; +using Phantom.Common.Data; namespace Phantom.Controller; diff --git a/Controller/Phantom.Controller/ConnectionKeyFiles.cs b/Controller/Phantom.Controller/ConnectionKeyFiles.cs index d2856c5..1fc3736 100644 --- a/Controller/Phantom.Controller/ConnectionKeyFiles.cs +++ b/Controller/Phantom.Controller/ConnectionKeyFiles.cs @@ -1,5 +1,5 @@ using NetMQ; -using Phantom.Common.Data.Agent; +using Phantom.Common.Data; using Phantom.Common.Logging; using Phantom.Utils.Cryptography; using Phantom.Utils.IO; diff --git a/Controller/Phantom.Controller/Program.cs b/Controller/Phantom.Controller/Program.cs index c09f980..78190b4 100644 --- a/Controller/Phantom.Controller/Program.cs +++ b/Controller/Phantom.Controller/Program.cs @@ -52,7 +52,7 @@ try { } var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString); - var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, shutdownCancellationToken); + var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, webKeyData.AuthToken, shutdownCancellationToken); PhantomLogger.Root.InformationHeading("Launching Phantom Panel server..."); diff --git a/Packages.props b/Packages.props index 18f9205..d086419 100644 --- a/Packages.props +++ b/Packages.props @@ -6,6 +6,7 @@ <PackageReference Update="Microsoft.EntityFrameworkCore.Relational" Version="7.0.11" /> <PackageReference Update="Microsoft.EntityFrameworkCore.Tools" Version="7.0.11" /> <PackageReference Update="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.11" /> + <PackageReference Update="System.Linq.Async" Version="4.0.0" /> </ItemGroup> <ItemGroup> diff --git a/PhantomPanel.sln b/PhantomPanel.sln index 12c0428..52569f2 100644 --- a/PhantomPanel.sln +++ b/PhantomPanel.sln @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data", "Comm EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Tests", "Common\Phantom.Common.Data.Tests\Phantom.Common.Data.Tests.csproj", "{435D7981-DFDA-46A0-8CD8-CD8C117935D7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Web", "Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj", "{BC969D0B-0019-48E0-9FAF-F5CC906AAF09}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages.Agent", "Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}" @@ -58,7 +60,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Bootstrap", "We EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Components", "Web\Phantom.Web.Components\Phantom.Web.Components.csproj", "{3F4F9059-F869-42D3-B92C-90D27ADFC42D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Identity", "Web\Phantom.Web.Identity\Phantom.Web.Identity.csproj", "{A9870842-FE7A-4760-95DC-9D485DDDA31F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Web.Services", "Web\Phantom.Web.Services\Phantom.Web.Services.csproj", "{7B0EEE34-A586-4629-AC51-16757DE53261}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -90,6 +92,10 @@ Global {435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Debug|Any CPU.Build.0 = Debug|Any CPU {435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.ActiveCfg = Release|Any CPU {435D7981-DFDA-46A0-8CD8-CD8C117935D7}.Release|Any CPU.Build.0 = Release|Any CPU + {BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC969D0B-0019-48E0-9FAF-F5CC906AAF09}.Release|Any CPU.Build.0 = Release|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Debug|Any CPU.Build.0 = Debug|Any CPU {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -150,10 +156,10 @@ Global {3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Debug|Any CPU.Build.0 = Debug|Any CPU {3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.ActiveCfg = Release|Any CPU {3F4F9059-F869-42D3-B92C-90D27ADFC42D}.Release|Any CPU.Build.0 = Release|Any CPU - {A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A9870842-FE7A-4760-95DC-9D485DDDA31F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A9870842-FE7A-4760-95DC-9D485DDDA31F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A9870842-FE7A-4760-95DC-9D485DDDA31F}.Release|Any CPU.Build.0 = Release|Any CPU + {7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B0EEE34-A586-4629-AC51-16757DE53261}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B0EEE34-A586-4629-AC51-16757DE53261}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {418BE1BF-9F63-4B46-B4E4-DF64C3B3DDA7} = {F5878792-64C8-4ECF-A075-66341FF97127} @@ -165,6 +171,7 @@ Global {95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {6E798DEB-8921-41A2-8AFB-E4416A9E0704} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5} + {BC969D0B-0019-48E0-9FAF-F5CC906AAF09} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} {E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} {81625B4A-3DB6-48BD-A739-D23DA02107D1} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} @@ -178,6 +185,6 @@ Global {7CA2E5FE-E507-4DC6-930C-E18711A9F856} = {92B26F48-235F-4500-BD55-800F06A0BA39} {83FA86DB-34E4-4C2C-832C-90F491CA10C7} = {92B26F48-235F-4500-BD55-800F06A0BA39} {3F4F9059-F869-42D3-B92C-90D27ADFC42D} = {92B26F48-235F-4500-BD55-800F06A0BA39} - {A9870842-FE7A-4760-95DC-9D485DDDA31F} = {92B26F48-235F-4500-BD55-800F06A0BA39} + {7B0EEE34-A586-4629-AC51-16757DE53261} = {92B26F48-235F-4500-BD55-800F06A0BA39} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 9cd823e..a0be6c3 100644 --- a/README.md +++ b/README.md @@ -149,8 +149,8 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit - `Controller` starts the Controller. - `Web` starts the Web server. - `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents. - - `Controller + Agent` starts the Controller and Agent 1. - - `Controller + Agent x3` starts the Controller and Agent 1, 2, and 3. + - `Controller + Web + Agent` starts the Controller and Agent 1. + - `Controller + Web + Agent x3` starts the Controller and Agent 1, 2, and 3. ## Bootstrap diff --git a/Utils/Phantom.Utils.Events/SimpleObservableState.cs b/Utils/Phantom.Utils.Events/SimpleObservableState.cs new file mode 100644 index 0000000..dd16f3d --- /dev/null +++ b/Utils/Phantom.Utils.Events/SimpleObservableState.cs @@ -0,0 +1,20 @@ +using Serilog; + +namespace Phantom.Utils.Events; + +public sealed class SimpleObservableState<T> : ObservableState<T> { + public T Value { get; private set; } + + public SimpleObservableState(ILogger logger, T initialValue) : base(logger) { + this.Value = initialValue; + } + + public void SetTo(T newValue) { + this.Value = newValue; + Update(); + } + + protected override T GetData() { + return Value; + } +} diff --git a/Utils/Phantom.Utils.Rpc/Message/IReply.cs b/Utils/Phantom.Utils.Rpc/Message/IReply.cs index de29f5e..34e4b17 100644 --- a/Utils/Phantom.Utils.Rpc/Message/IReply.cs +++ b/Utils/Phantom.Utils.Rpc/Message/IReply.cs @@ -1,4 +1,4 @@ -namespace Phantom.Utils.Rpc.Message; +namespace Phantom.Utils.Rpc.Message; public interface IReply { uint SequenceId { get; } diff --git a/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs b/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs index 7cc0331..b160af9 100644 --- a/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs +++ b/Utils/Phantom.Utils.Rpc/RpcConnectionToServer.cs @@ -2,7 +2,7 @@ using NetMQ.Sockets; using Phantom.Utils.Rpc.Message; -namespace Phantom.Utils.Rpc; +namespace Phantom.Utils.Rpc; public sealed class RpcConnectionToServer<TListener> { private readonly ClientSocket socket; diff --git a/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs b/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs index be3350a..9407074 100644 --- a/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs +++ b/Utils/Phantom.Utils/Collections/EnumerableExtensions.cs @@ -32,4 +32,14 @@ public static class EnumerableExtensions { return builder.ToImmutable(); } + + public static async Task<ImmutableDictionary<TKey, TValue>> ToImmutableDictionaryAsync<TSource, TKey, TValue>(this IAsyncEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TValue> valueSelector, CancellationToken cancellationToken = default) where TKey : notnull { + var builder = ImmutableDictionary.CreateBuilder<TKey, TValue>(); + + await foreach (var element in source.WithCancellation(cancellationToken)) { + builder.Add(keySelector(element), valueSelector(element)); + } + + return builder.ToImmutable(); + } } diff --git a/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs b/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs index 9d798b1..8f66157 100644 --- a/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs +++ b/Utils/Phantom.Utils/Collections/RwLockedDictionary.cs @@ -69,6 +69,26 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull { } } + public bool GetOrAdd(TKey key, Func<TKey, TValue> valueFactory, out TValue value) { + rwLock.EnterUpgradeableReadLock(); + try { + if (dict.TryGetValue(key, out var existingValue)) { + value = existingValue; + return false; + } + + rwLock.EnterWriteLock(); + try { + dict[key] = value = valueFactory(key); + return true; + } finally { + rwLock.ExitWriteLock(); + } + } finally { + rwLock.ExitUpgradeableReadLock(); + } + } + public bool TryAdd(TKey key, TValue newValue) { rwLock.EnterWriteLock(); try { @@ -108,11 +128,11 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull { } } - public bool TryReplace(TKey key, Func<TValue, TValue> replacementValue) { - return TryReplaceIf(key, replacementValue, static _ => true); + public bool TryReplace(TKey key, Func<TValue, TValue> replacementValueFactory) { + return TryReplaceIf(key, replacementValueFactory, static _ => true); } - public bool TryReplaceIf(TKey key, Func<TValue, TValue> replacementValue, Predicate<TValue> replaceCondition) { + public bool TryReplaceIf(TKey key, Func<TValue, TValue> replacementValueFactory, Predicate<TValue> replaceCondition) { rwLock.EnterUpgradeableReadLock(); try { if (!dict.TryGetValue(key, out var oldValue) || !replaceCondition(oldValue)) { @@ -121,7 +141,7 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull { rwLock.EnterWriteLock(); try { - dict[key] = replacementValue(oldValue); + dict[key] = replacementValueFactory(oldValue); return true; } finally { rwLock.ExitWriteLock(); @@ -131,11 +151,11 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull { } } - public bool ReplaceAll(Func<TValue, TValue> replacementValue) { + public bool ReplaceAll(Func<TValue, TValue> replacementValueFactory) { rwLock.EnterWriteLock(); try { foreach (var (key, oldValue) in dict) { - dict[key] = replacementValue(oldValue); + dict[key] = replacementValueFactory(oldValue); } return dict.Count > 0; @@ -144,7 +164,7 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull { } } - public bool ReplaceAllIf(Func<TValue, TValue> replacementValue, Predicate<TValue> replaceCondition) { + public bool ReplaceAllIf(Func<TValue, TValue> replacementValueFactory, Predicate<TValue> replaceCondition) { rwLock.EnterUpgradeableReadLock(); try { bool hasChanged = false; @@ -157,7 +177,7 @@ public sealed class RwLockedDictionary<TKey, TValue> where TKey : notnull { } hasChanged = true; - dict[key] = replacementValue(oldValue); + dict[key] = replacementValueFactory(oldValue); } } } finally { diff --git a/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs b/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs index 8b0ddcd..ecbfce4 100644 --- a/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs +++ b/Utils/Phantom.Utils/Collections/RwLockedObservableDictionary.cs @@ -47,6 +47,10 @@ public sealed class RwLockedObservableDictionary<TKey, TValue> where TKey : notn public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) { return dict.TryGetValue(key, out value); } + + public bool GetOrAdd(TKey key, Func<TKey, TValue> valueFactory, out TValue value) { + return FireCollectionChangedIf(dict.GetOrAdd(key, valueFactory, out value)); + } public bool TryAdd(TKey key, TValue newValue) { return FireCollectionChangedIf(dict.TryAdd(key, newValue)); diff --git a/Utils/Phantom.Utils/Tasks/Result.cs b/Utils/Phantom.Utils/Tasks/Result.cs index a925efe..eba7082 100644 --- a/Utils/Phantom.Utils/Tasks/Result.cs +++ b/Utils/Phantom.Utils/Tasks/Result.cs @@ -2,17 +2,57 @@ public abstract record Result<TValue, TError> { private Result() {} + + public abstract TValue Value { get; init; } + public abstract TError Error { get; init; } + + public static implicit operator Result<TValue, TError>(TValue value) { + return new Ok(value); + } - public sealed record Ok(TValue Value) : Result<TValue, TError>; + public static implicit operator Result<TValue, TError>(TError error) { + return new Fail(error); + } + + public static implicit operator bool(Result<TValue, TError> result) { + return result is Ok; + } - public sealed record Fail(TError Error) : Result<TValue, TError>; + public sealed record Ok(TValue Value) : Result<TValue, TError> { + public override TError Error { + get => throw new InvalidOperationException("Attempted to get error from Ok result."); + init {} + } + } + + public sealed record Fail(TError Error) : Result<TValue, TError> { + public override TValue Value { + get => throw new InvalidOperationException("Attempted to get value from Fail result."); + init {} + } + } } public abstract record Result<TError> { private Result() {} + + public abstract TError Error { get; init; } + public static implicit operator Result<TError>(TError error) { + return new Fail(error); + } + + public static implicit operator bool(Result<TError> result) { + return result is Ok; + } + public sealed record Ok : Result<TError> { internal static Ok Instance { get; } = new (); + + public override TError Error { + get => throw new InvalidOperationException("Attempted to get error from Ok result."); + init {} + } } public sealed record Fail(TError Error) : Result<TError>; diff --git a/Web/Phantom.Web.Identity/Authentication/PhantomLoginManager.cs b/Web/Phantom.Web.Identity/Authentication/PhantomLoginManager.cs deleted file mode 100644 index eb3b500..0000000 --- a/Web/Phantom.Web.Identity/Authentication/PhantomLoginManager.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Phantom.Common.Logging; -using Phantom.Controller.Services.Users; -using Phantom.Utils.Cryptography; -using Phantom.Web.Identity.Interfaces; -using ILogger = Serilog.ILogger; - -namespace Phantom.Web.Identity.Authentication; - -public sealed class PhantomLoginManager { - private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginManager>(); - - public static bool IsAuthenticated(ClaimsPrincipal user) { - return user.Identity is { IsAuthenticated: true }; - } - - private readonly INavigation navigation; - private readonly UserManager userManager; - private readonly PhantomLoginStore loginStore; - private readonly ILoginEvents loginEvents; - - public PhantomLoginManager(INavigation navigation, UserManager userManager, PhantomLoginStore loginStore, ILoginEvents loginEvents) { - this.navigation = navigation; - this.userManager = userManager; - this.loginStore = loginStore; - this.loginEvents = loginEvents; - } - - public async Task<bool> SignIn(string username, string password, string? returnUrl = null) { - if (await userManager.GetAuthenticated(username, password) == null) { - return false; - } - - Logger.Debug("Created login token for {Username}.", username); - - string token = TokenGenerator.Create(60); - loginStore.Add(token, username, password, returnUrl ?? string.Empty); - navigation.NavigateTo("login" + QueryString.Create("token", token), forceLoad: true); - - return true; - } - - internal async Task<SignInResult?> ProcessToken(string token) { - var entry = loginStore.Pop(token); - if (entry == null) { - return null; - } - - var user = await userManager.GetAuthenticated(entry.Username, entry.Password); - if (user == null) { - return null; - } - - Logger.Information("Successful login for {Username}.", user.Name); - loginEvents.UserLoggedIn(user); - - var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); - identity.AddClaim(new Claim(ClaimTypes.Name, user.Name)); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.UserGuid.ToString())); - - var authenticationProperties = new AuthenticationProperties { - IsPersistent = true - }; - - return new SignInResult(new ClaimsPrincipal(identity), authenticationProperties, entry.ReturnUrl); - } - - internal sealed record SignInResult(ClaimsPrincipal ClaimsPrincipal, AuthenticationProperties AuthenticationProperties, string ReturnUrl); - - internal void OnSignedOut(ClaimsPrincipal user) { - if (UserManager.GetAuthenticatedUserId(user) is {} userGuid) { - loginEvents.UserLoggedOut(userGuid); - } - } -} diff --git a/Web/Phantom.Web.Identity/Authentication/PhantomLoginStore.cs b/Web/Phantom.Web.Identity/Authentication/PhantomLoginStore.cs deleted file mode 100644 index 9e7d0d1..0000000 --- a/Web/Phantom.Web.Identity/Authentication/PhantomLoginStore.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using Phantom.Common.Logging; -using Phantom.Utils.Tasks; -using ILogger = Serilog.ILogger; - -namespace Phantom.Web.Identity.Authentication; - -public sealed class PhantomLoginStore { - private static readonly ILogger Logger = PhantomLogger.Create<PhantomLoginStore>(); - private static readonly TimeSpan ExpirationTime = TimeSpan.FromMinutes(1); - - internal static Func<IServiceProvider, PhantomLoginStore> Create(CancellationToken cancellationToken) { - return provider => new PhantomLoginStore(provider.GetRequiredService<TaskManager>(), cancellationToken); - } - - private readonly ConcurrentDictionary<string, LoginEntry> loginEntries = new (); - private readonly CancellationToken cancellationToken; - - private PhantomLoginStore(TaskManager taskManager, CancellationToken cancellationToken) { - this.cancellationToken = cancellationToken; - taskManager.Run("Web login entry expiration loop", RunExpirationLoop); - } - - private async Task RunExpirationLoop() { - try { - while (true) { - await Task.Delay(ExpirationTime, cancellationToken); - - foreach (var (token, entry) in loginEntries) { - if (entry.IsExpired) { - Logger.Debug("Expired login entry for {Username}.", entry.Username); - loginEntries.TryRemove(token, out _); - } - } - } - } finally { - Logger.Information("Expiration loop stopped."); - } - } - - internal void Add(string token, string username, string password, string returnUrl) { - loginEntries[token] = new LoginEntry(username, password, returnUrl, Stopwatch.StartNew()); - } - - internal LoginEntry? Pop(string token) { - if (!loginEntries.TryRemove(token, out var entry)) { - return null; - } - - if (entry.IsExpired) { - Logger.Debug("Expired login entry for {Username}.", entry.Username); - return null; - } - - return entry; - } - - internal sealed record LoginEntry(string Username, string Password, string ReturnUrl, Stopwatch AddedTime) { - public bool IsExpired => AddedTime.Elapsed >= ExpirationTime; - } -} diff --git a/Web/Phantom.Web.Identity/Interfaces/ILoginEvents.cs b/Web/Phantom.Web.Identity/Interfaces/ILoginEvents.cs deleted file mode 100644 index 83c626b..0000000 --- a/Web/Phantom.Web.Identity/Interfaces/ILoginEvents.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Phantom.Controller.Database.Entities; - -namespace Phantom.Web.Identity.Interfaces; - -public interface ILoginEvents { - void UserLoggedIn(UserEntity user); - void UserLoggedOut(Guid userGuid); -} diff --git a/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj b/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj deleted file mode 100644 index 32fc9d8..0000000 --- a/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj +++ /dev/null @@ -1,26 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> - - <PropertyGroup> - <ImplicitUsings>enable</ImplicitUsings> - <Nullable>enable</Nullable> - </PropertyGroup> - - <PropertyGroup> - <OutputType>Library</OutputType> - </PropertyGroup> - - <ItemGroup> - <SupportedPlatform Include="browser" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" /> - <PackageReference Include="Microsoft.AspNetCore.Components.Web" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> - <ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" /> - </ItemGroup> - -</Project> diff --git a/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs b/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs deleted file mode 100644 index 8159bd9..0000000 --- a/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.AspNetCore.Components.Server; -using Phantom.Web.Identity.Authentication; -using Phantom.Web.Identity.Authorization; - -namespace Phantom.Web.Identity; - -public static class PhantomIdentityExtensions { - public static void AddPhantomIdentity(this IServiceCollection services, CancellationToken cancellationToken) { - services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(ConfigureIdentityCookie); - services.AddAuthorization(ConfigureAuthorization); - - services.AddSingleton(PhantomLoginStore.Create(cancellationToken)); - services.AddScoped<PhantomLoginManager>(); - - services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>(); - services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>(); - } - - public static void UsePhantomIdentity(this IApplicationBuilder application) { - application.UseAuthentication(); - application.UseAuthorization(); - application.UseWhen(PhantomIdentityMiddleware.AcceptsPath, static app => app.UseMiddleware<PhantomIdentityMiddleware>()); - } - - private static void ConfigureIdentityCookie(CookieAuthenticationOptions o) { - o.Cookie.Name = "Phantom.Identity"; - o.Cookie.HttpOnly = true; - o.Cookie.SameSite = SameSiteMode.Lax; - - o.ExpireTimeSpan = TimeSpan.FromDays(30); - o.SlidingExpiration = true; - - o.LoginPath = PhantomIdentityMiddleware.LoginPath; - o.LogoutPath = PhantomIdentityMiddleware.LogoutPath; - o.AccessDeniedPath = PhantomIdentityMiddleware.LoginPath; - } - - private static void ConfigureAuthorization(AuthorizationOptions o) { - foreach (var permission in Permission.All) { - o.AddPolicy(permission.Id, policy => policy.Requirements.Add(new PermissionBasedPolicyRequirement(permission))); - } - } -} diff --git a/Web/Phantom.Web.Identity/PhantomIdentityMiddleware.cs b/Web/Phantom.Web.Identity/PhantomIdentityMiddleware.cs deleted file mode 100644 index f24ce11..0000000 --- a/Web/Phantom.Web.Identity/PhantomIdentityMiddleware.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Authentication; -using Phantom.Web.Identity.Authentication; -using Phantom.Web.Identity.Interfaces; - -namespace Phantom.Web.Identity; - -sealed class PhantomIdentityMiddleware { - public const string LoginPath = "/login"; - public const string LogoutPath = "/logout"; - - public static bool AcceptsPath(HttpContext context) { - var path = context.Request.Path; - return path == LoginPath || path == LogoutPath; - } - - private readonly RequestDelegate next; - - public PhantomIdentityMiddleware(RequestDelegate next) { - this.next = next; - } - - [SuppressMessage("ReSharper", "UnusedMember.Global")] - public async Task InvokeAsync(HttpContext context, INavigation navigation, PhantomLoginManager loginManager) { - var path = context.Request.Path; - if (path == LoginPath && context.Request.Query.TryGetValue("token", out var tokens) && tokens[0] is {} token && await loginManager.ProcessToken(token) is {} result) { - await context.SignInAsync(result.ClaimsPrincipal, result.AuthenticationProperties); - context.Response.Redirect(navigation.BasePath + result.ReturnUrl); - } - else if (path == LogoutPath) { - loginManager.OnSignedOut(context.User); - await context.SignOutAsync(); - context.Response.Redirect(navigation.BasePath); - } - else { - await next.Invoke(context); - } - } -} diff --git a/Web/Phantom.Web.Services/Agents/AgentManager.cs b/Web/Phantom.Web.Services/Agents/AgentManager.cs new file mode 100644 index 0000000..5d4955d --- /dev/null +++ b/Web/Phantom.Web.Services/Agents/AgentManager.cs @@ -0,0 +1,24 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.Agent; +using Phantom.Common.Logging; +using Phantom.Utils.Events; + +namespace Phantom.Web.Services.Agents; + +public sealed class AgentManager { + private readonly SimpleObservableState<ImmutableArray<AgentWithStats>> agents = new (PhantomLogger.Create<AgentManager>("Agents"), ImmutableArray<AgentWithStats>.Empty); + + public EventSubscribers<ImmutableArray<AgentWithStats>> AgentsChanged => agents.Subs; + + internal void RefreshAgents(ImmutableArray<AgentWithStats> newAgents) { + agents.SetTo(newAgents); + } + + public ImmutableArray<AgentWithStats> GetAll() { + return agents.Value; + } + + public ImmutableDictionary<Guid, AgentWithStats> ToDictionaryByGuid() { + return agents.Value.ToImmutableDictionary(static agent => agent.Guid); + } +} diff --git a/Web/Phantom.Web.Services/Authentication/CustomAuthenticationStateProvider.cs b/Web/Phantom.Web.Services/Authentication/CustomAuthenticationStateProvider.cs new file mode 100644 index 0000000..a53df81 --- /dev/null +++ b/Web/Phantom.Web.Services/Authentication/CustomAuthenticationStateProvider.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Server; + +namespace Phantom.Web.Services.Authentication; + +public sealed class CustomAuthenticationStateProvider : ServerAuthenticationStateProvider { + private readonly UserSessionManager sessionManager; + private readonly UserSessionBrowserStorage sessionBrowserStorage; + private bool isLoaded; + + public CustomAuthenticationStateProvider(UserSessionManager sessionManager, UserSessionBrowserStorage sessionBrowserStorage) { + this.sessionManager = sessionManager; + this.sessionBrowserStorage = sessionBrowserStorage; + } + + public override async Task<AuthenticationState> GetAuthenticationStateAsync() { + if (!isLoaded) { + var stored = await sessionBrowserStorage.Get(); + if (stored != null) { + var session = sessionManager.FindWithToken(stored.UserGuid, stored.Token); + if (session != null) { + SetLoadedSession(session); + } + } + } + + return await base.GetAuthenticationStateAsync(); + } + + internal void SetLoadedSession(UserInfo user) { + isLoaded = true; + SetAuthenticationState(Task.FromResult(new AuthenticationState(user.AsClaimsPrincipal))); + } + + internal void SetUnloadedSession() { + isLoaded = false; + SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal()))); + } +} diff --git a/Web/Phantom.Web.Services/Authentication/UserInfo.cs b/Web/Phantom.Web.Services/Authentication/UserInfo.cs new file mode 100644 index 0000000..ceb441d --- /dev/null +++ b/Web/Phantom.Web.Services/Authentication/UserInfo.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using Phantom.Common.Data.Web.Users; + +namespace Phantom.Web.Services.Authentication; + +public sealed record UserInfo(Guid UserGuid, string Username, PermissionSet Permissions) { + private const string AuthenticationType = "Phantom"; + + internal ClaimsPrincipal AsClaimsPrincipal { + get { + var identity = new ClaimsIdentity(AuthenticationType); + + identity.AddClaim(new Claim(ClaimTypes.Name, Username)); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, UserGuid.ToString())); + + return new ClaimsPrincipal(identity); + } + } + + public static Guid? TryGetGuid(ClaimsPrincipal principal) { + return principal.Identity is { IsAuthenticated: true, AuthenticationType: AuthenticationType } && principal.FindFirstValue(ClaimTypes.NameIdentifier) is {} guidStr && Guid.TryParse(guidStr, out var guid) ? guid : null; + } +} diff --git a/Web/Phantom.Web.Services/Authentication/UserLoginManager.cs b/Web/Phantom.Web.Services/Authentication/UserLoginManager.cs new file mode 100644 index 0000000..52b59fa --- /dev/null +++ b/Web/Phantom.Web.Services/Authentication/UserLoginManager.cs @@ -0,0 +1,63 @@ +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Logging; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Web.Services.Rpc; +using ILogger = Serilog.ILogger; + +namespace Phantom.Web.Services.Authentication; + +public sealed class UserLoginManager { + private static readonly ILogger Logger = PhantomLogger.Create<UserLoginManager>(); + + private readonly INavigation navigation; + private readonly UserSessionManager sessionManager; + private readonly UserSessionBrowserStorage sessionBrowserStorage; + private readonly CustomAuthenticationStateProvider authenticationStateProvider; + private readonly ControllerConnection controllerConnection; + + public UserLoginManager(INavigation navigation, UserSessionManager sessionManager, UserSessionBrowserStorage sessionBrowserStorage, CustomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) { + this.navigation = navigation; + this.sessionManager = sessionManager; + this.sessionBrowserStorage = sessionBrowserStorage; + this.authenticationStateProvider = authenticationStateProvider; + this.controllerConnection = controllerConnection; + } + + public async Task<bool> LogIn(string username, string password, string? returnUrl = null) { + LogInSuccess? success; + try { + success = await controllerConnection.Send<LogInMessage, LogInSuccess?>(new LogInMessage(username, password), TimeSpan.FromSeconds(30)); + } catch (Exception e) { + Logger.Error(e, "Could not log in {Username}.", username); + return false; + } + + if (success == null) { + return false; + } + + Logger.Information("Successfully logged in {Username}.", username); + + var userGuid = success.UserGuid; + var userInfo = new UserInfo(userGuid, username, success.Permissions); + var token = success.Token; + + await sessionBrowserStorage.Store(userGuid, token); + sessionManager.Add(userInfo, token); + + authenticationStateProvider.SetLoadedSession(userInfo); + await navigation.NavigateTo(returnUrl ?? string.Empty); + + return true; + } + + public async Task LogOut() { + var stored = await sessionBrowserStorage.Delete(); + if (stored != null) { + sessionManager.Remove(stored.UserGuid, stored.Token); + } + + await navigation.NavigateTo(string.Empty); + authenticationStateProvider.SetUnloadedSession(); + } +} diff --git a/Web/Phantom.Web.Services/Authentication/UserSessionBrowserStorage.cs b/Web/Phantom.Web.Services/Authentication/UserSessionBrowserStorage.cs new file mode 100644 index 0000000..e67e6e1 --- /dev/null +++ b/Web/Phantom.Web.Services/Authentication/UserSessionBrowserStorage.cs @@ -0,0 +1,48 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using Phantom.Common.Logging; +using ILogger = Serilog.ILogger; + +namespace Phantom.Web.Services.Authentication; + +public sealed class UserSessionBrowserStorage { + private static readonly ILogger Logger = PhantomLogger.Create<UserSessionBrowserStorage>(); + + private const string SessionTokenKey = "PhantomSession"; + + private readonly ProtectedLocalStorage localStorage; + + public UserSessionBrowserStorage(ProtectedLocalStorage localStorage) { + this.localStorage = localStorage; + } + + internal sealed record LocalStorageEntry(Guid UserGuid, ImmutableArray<byte> Token); + + internal async Task<LocalStorageEntry?> Get() { + try { + var result = await localStorage.GetAsync<LocalStorageEntry>(SessionTokenKey); + return result.Success ? result.Value : null; + } catch (InvalidOperationException) { + return null; + } catch (CryptographicException) { + return null; + } catch (Exception e) { + Logger.Error(e, "Could not read local storage entry."); + return null; + } + } + + internal async Task Store(Guid userGuid, ImmutableArray<byte> token) { + await localStorage.SetAsync(SessionTokenKey, new LocalStorageEntry(userGuid, token)); + } + + internal async Task<LocalStorageEntry?> Delete() { + var oldEntry = await Get(); + if (oldEntry != null) { + await localStorage.DeleteAsync(SessionTokenKey); + } + + return oldEntry; + } +} diff --git a/Web/Phantom.Web.Services/Authentication/UserSessionManager.cs b/Web/Phantom.Web.Services/Authentication/UserSessionManager.cs new file mode 100644 index 0000000..3e5e424 --- /dev/null +++ b/Web/Phantom.Web.Services/Authentication/UserSessionManager.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; + +namespace Phantom.Web.Services.Authentication; + +public sealed class UserSessionManager { + private readonly ConcurrentDictionary<Guid, UserSessions> userSessions = new (); + + internal void Add(UserInfo user, ImmutableArray<byte> token) { + userSessions.AddOrUpdate( + user.UserGuid, + static (_, u) => new UserSessions(u), + static (_, sessions, u) => sessions.WithUserInfo(u), + user + ).AddToken(token); + } + + internal UserInfo? Find(Guid userGuid) { + return userSessions.TryGetValue(userGuid, out var sessions) ? sessions.UserInfo : null; + } + + internal UserInfo? FindWithToken(Guid userGuid, ImmutableArray<byte> token) { + return userSessions.TryGetValue(userGuid, out var sessions) && sessions.HasToken(token) ? sessions.UserInfo : null; + } + + internal void Remove(Guid userGuid, ImmutableArray<byte> token) { + if (userSessions.TryGetValue(userGuid, out var sessions)) { + sessions.RemoveToken(token); + } + } +} diff --git a/Web/Phantom.Web.Services/Authentication/UserSessions.cs b/Web/Phantom.Web.Services/Authentication/UserSessions.cs new file mode 100644 index 0000000..64a82a8 --- /dev/null +++ b/Web/Phantom.Web.Services/Authentication/UserSessions.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; +using System.Security.Cryptography; + +namespace Phantom.Web.Services.Authentication; + +sealed class UserSessions { + public UserInfo UserInfo { get; } + + private readonly List<ImmutableArray<byte>> tokens = new (); + + public UserSessions(UserInfo userInfo) { + UserInfo = userInfo; + } + + private UserSessions(UserInfo userInfo, List<ImmutableArray<byte>> tokens) : this(userInfo) { + this.tokens.AddRange(tokens); + } + + public UserSessions WithUserInfo(UserInfo user) { + List<ImmutableArray<byte>> tokensCopy; + lock (tokens) { + tokensCopy = new List<ImmutableArray<byte>>(tokens); + } + + return new UserSessions(user, tokensCopy); + } + + public void AddToken(ImmutableArray<byte> token) { + lock (tokens) { + if (!HasToken(token)) { + tokens.Add(token); + } + } + } + + public bool HasToken(ImmutableArray<byte> token) { + return FindTokenIndex(token) != -1; + } + + private int FindTokenIndex(ImmutableArray<byte> token) { + lock (tokens) { + return tokens.FindIndex(t => CryptographicOperations.FixedTimeEquals(t.AsSpan(), token.AsSpan())); + } + } + + public void RemoveToken(ImmutableArray<byte> token) { + lock (tokens) { + int index = FindTokenIndex(token); + if (index != -1) { + tokens.RemoveAt(index); + } + } + } +} diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyHandler.cs b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyHandler.cs similarity index 94% rename from Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyHandler.cs rename to Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyHandler.cs index 7ec7167..2e7e976 100644 --- a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyHandler.cs +++ b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyHandler.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -namespace Phantom.Web.Identity.Authorization; +namespace Phantom.Web.Services.Authorization; sealed class PermissionBasedPolicyHandler : AuthorizationHandler<PermissionBasedPolicyRequirement> { private readonly PermissionManager permissionManager; diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyRequirement.cs b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyRequirement.cs similarity index 63% rename from Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyRequirement.cs rename to Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyRequirement.cs index 05855a1..0eec338 100644 --- a/Web/Phantom.Web.Identity/Authorization/PermissionBasedPolicyRequirement.cs +++ b/Web/Phantom.Web.Services/Authorization/PermissionBasedPolicyRequirement.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Authorization; -using Phantom.Web.Identity.Data; +using Phantom.Common.Data.Web.Users; -namespace Phantom.Web.Identity.Authorization; +namespace Phantom.Web.Services.Authorization; sealed record PermissionBasedPolicyRequirement(Permission Permission) : IAuthorizationRequirement; diff --git a/Web/Phantom.Web.Services/Authorization/PermissionManager.cs b/Web/Phantom.Web.Services/Authorization/PermissionManager.cs new file mode 100644 index 0000000..5d5d953 --- /dev/null +++ b/Web/Phantom.Web.Services/Authorization/PermissionManager.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Phantom.Common.Data.Web.Users; +using Phantom.Web.Services.Authentication; +using UserInfo = Phantom.Web.Services.Authentication.UserInfo; + +namespace Phantom.Web.Services.Authorization; + +public sealed class PermissionManager { + private readonly UserSessionManager sessionManager; + + public PermissionManager(UserSessionManager sessionManager) { + this.sessionManager = sessionManager; + } + + public PermissionSet GetPermissions(ClaimsPrincipal user) { + return UserInfo.TryGetGuid(user) is {} guid && sessionManager.Find(guid) is {} info ? info.Permissions : PermissionSet.None; + } + + public bool CheckPermission(ClaimsPrincipal user, Permission permission) { + return GetPermissions(user).Check(permission); + } +} diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionView.razor b/Web/Phantom.Web.Services/Authorization/PermissionView.razor similarity index 92% rename from Web/Phantom.Web.Identity/Authorization/PermissionView.razor rename to Web/Phantom.Web.Services/Authorization/PermissionView.razor index b4542e2..23638f6 100644 --- a/Web/Phantom.Web.Identity/Authorization/PermissionView.razor +++ b/Web/Phantom.Web.Services/Authorization/PermissionView.razor @@ -1,5 +1,5 @@ @using Microsoft.AspNetCore.Components.Authorization -@using Phantom.Web.Identity.Data +@using Phantom.Common.Data.Web.Users @inject PermissionManager PermissionManager <AuthorizeView> diff --git a/Web/Phantom.Web.Services/Events/EventLogManager.cs b/Web/Phantom.Web.Services/Events/EventLogManager.cs new file mode 100644 index 0000000..588b2dc --- /dev/null +++ b/Web/Phantom.Web.Services/Events/EventLogManager.cs @@ -0,0 +1,19 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.EventLog; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Web.Services.Rpc; + +namespace Phantom.Web.Services.Events; + +public sealed class EventLogManager { + private readonly ControllerConnection controllerConnection; + + public EventLogManager(ControllerConnection controllerConnection) { + this.controllerConnection = controllerConnection; + } + + public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) { + var message = new GetEventLogMessage(count); + return controllerConnection.Send<GetEventLogMessage, ImmutableArray<EventLogItem>>(message, cancellationToken); + } +} diff --git a/Web/Phantom.Web.Identity/Interfaces/INavigation.cs b/Web/Phantom.Web.Services/INavigation.cs similarity index 65% rename from Web/Phantom.Web.Identity/Interfaces/INavigation.cs rename to Web/Phantom.Web.Services/INavigation.cs index d509827..f8aabee 100644 --- a/Web/Phantom.Web.Identity/Interfaces/INavigation.cs +++ b/Web/Phantom.Web.Services/INavigation.cs @@ -1,9 +1,9 @@ using System.Diagnostics.CodeAnalysis; -namespace Phantom.Web.Identity.Interfaces; +namespace Phantom.Web.Services; public interface INavigation { string BasePath { get; } bool GetQueryParameter(string key, [MaybeNullWhen(false)] out string value); - void NavigateTo(string url, bool forceLoad = false); + Task NavigateTo(string url, bool forceLoad = false); } diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceLogHtmlFilters.cs b/Web/Phantom.Web.Services/Instances/InstanceLogHtmlFilters.cs similarity index 95% rename from Controller/Phantom.Controller.Services/Instances/InstanceLogHtmlFilters.cs rename to Web/Phantom.Web.Services/Instances/InstanceLogHtmlFilters.cs index dde2592..8384d2c 100644 --- a/Controller/Phantom.Controller.Services/Instances/InstanceLogHtmlFilters.cs +++ b/Web/Phantom.Web.Services/Instances/InstanceLogHtmlFilters.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text.RegularExpressions; -namespace Phantom.Controller.Services.Instances; +namespace Phantom.Web.Services.Instances; static partial class InstanceLogHtmlFilters { /// <summary> diff --git a/Web/Phantom.Web.Services/Instances/InstanceLogManager.cs b/Web/Phantom.Web.Services/Instances/InstanceLogManager.cs new file mode 100644 index 0000000..7cc800e --- /dev/null +++ b/Web/Phantom.Web.Services/Instances/InstanceLogManager.cs @@ -0,0 +1,44 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Phantom.Common.Logging; +using Phantom.Utils.Collections; +using Phantom.Utils.Events; +using ILogger = Serilog.ILogger; + +namespace Phantom.Web.Services.Instances; + +public sealed class InstanceLogManager { + private const int RetainedLines = 1000; + + private readonly ConcurrentDictionary<Guid, ObservableInstanceLogs> logsByInstanceGuid = new (); + + private ObservableInstanceLogs GetInstanceLogs(Guid instanceGuid) { + return logsByInstanceGuid.GetOrAdd(instanceGuid, static _ => new ObservableInstanceLogs(PhantomLogger.Create<InstanceLogManager, ObservableInstanceLogs>())); + } + + internal void AddLines(Guid instanceGuid, ImmutableArray<string> lines) { + GetInstanceLogs(instanceGuid).Add(lines); + } + + public EventSubscribers<RingBuffer<string>> GetSubs(Guid instanceGuid) { + return GetInstanceLogs(instanceGuid).Subs; + } + + private sealed class ObservableInstanceLogs : ObservableState<RingBuffer<string>> { + private readonly RingBuffer<string> log = new (RetainedLines); + + public ObservableInstanceLogs(ILogger logger) : base(logger) {} + + public void Add(ImmutableArray<string> lines) { + foreach (var line in lines) { + log.Add(InstanceLogHtmlFilters.Process(line)); + } + + Update(); + } + + protected override RingBuffer<string> GetData() { + return log; + } + } +} diff --git a/Web/Phantom.Web.Services/Instances/InstanceManager.cs b/Web/Phantom.Web.Services/Instances/InstanceManager.cs new file mode 100644 index 0000000..0891dc9 --- /dev/null +++ b/Web/Phantom.Web.Services/Instances/InstanceManager.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Instance; +using Phantom.Common.Data.Minecraft; +using Phantom.Common.Data.Replies; +using Phantom.Common.Data.Web.Instance; +using Phantom.Common.Logging; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Utils.Events; +using Phantom.Web.Services.Rpc; + +namespace Phantom.Web.Services.Instances; + +using InstanceDictionary = ImmutableDictionary<Guid, Instance>; + +public sealed class InstanceManager { + private readonly ControllerConnection controllerConnection; + private readonly SimpleObservableState<InstanceDictionary> instances = new (PhantomLogger.Create<InstanceManager>("Instances"), InstanceDictionary.Empty); + + public InstanceManager(ControllerConnection controllerConnection) { + this.controllerConnection = controllerConnection; + } + + public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs; + + internal void RefreshInstances(ImmutableArray<Instance> newInstances) { + instances.SetTo(newInstances.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid)); + } + + public InstanceDictionary GetAll() { + return instances.Value; + } + + public Instance? GetByGuid(Guid instanceGuid) { + return instances.Value.GetValueOrDefault(instanceGuid); + } + + public Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(Guid loggedInUserGuid, InstanceConfiguration configuration, CancellationToken cancellationToken) { + var message = new CreateOrUpdateInstanceMessage(loggedInUserGuid, configuration); + return controllerConnection.Send<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(message, cancellationToken); + } + + public Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(Guid loggedInUserGuid, Guid instanceGuid, CancellationToken cancellationToken) { + var message = new LaunchInstanceMessage(loggedInUserGuid, instanceGuid); + return controllerConnection.Send<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(message, cancellationToken); + } + + public Task<InstanceActionResult<StopInstanceResult>> StopInstance(Guid loggedInUserGuid, Guid instanceGuid, MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) { + var message = new StopInstanceMessage(loggedInUserGuid, instanceGuid, stopStrategy); + return controllerConnection.Send<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(message, cancellationToken); + } + + public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommandToInstance(Guid loggedInUserGuid, Guid instanceGuid, string command, CancellationToken cancellationToken) { + var message = new SendCommandToInstanceMessage(loggedInUserGuid, instanceGuid, command); + return controllerConnection.Send<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(message, cancellationToken); + } +} diff --git a/Web/Phantom.Web.Services/Phantom.Web.Services.csproj b/Web/Phantom.Web.Services/Phantom.Web.Services.csproj new file mode 100644 index 0000000..982f919 --- /dev/null +++ b/Web/Phantom.Web.Services/Phantom.Web.Services.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <PropertyGroup> + <OutputType>Library</OutputType> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Data.Web\Phantom.Common.Data.Web.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" /> + <ProjectReference Include="..\..\Utils\Phantom.Utils.Events\Phantom.Utils.Events.csproj" /> + </ItemGroup> + + <ItemGroup> + <AdditionalFiles Include="Authorization\PermissionView.razor" /> + </ItemGroup> + +</Project> diff --git a/Web/Phantom.Web.Services/PhantomWebServices.cs b/Web/Phantom.Web.Services/PhantomWebServices.cs new file mode 100644 index 0000000..ae0ec84 --- /dev/null +++ b/Web/Phantom.Web.Services/PhantomWebServices.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components.Authorization; +using Phantom.Common.Data.Web.Users; +using Phantom.Web.Services.Agents; +using Phantom.Web.Services.Authentication; +using Phantom.Web.Services.Authorization; +using Phantom.Web.Services.Events; +using Phantom.Web.Services.Instances; +using Phantom.Web.Services.Rpc; +using Phantom.Web.Services.Users; + +namespace Phantom.Web.Services; + +public static class PhantomWebServices { + public static void AddPhantomServices(this IServiceCollection services) { + services.AddSingleton<ControllerConnection>(); + services.AddSingleton<MessageListener>(); + + services.AddSingleton<AgentManager>(); + services.AddSingleton<InstanceManager>(); + services.AddSingleton<InstanceLogManager>(); + services.AddSingleton<EventLogManager>(); + + services.AddSingleton<UserManager>(); + services.AddSingleton<UserSessionManager>(); + services.AddSingleton<AuditLogManager>(); + services.AddScoped<UserLoginManager>(); + services.AddScoped<UserSessionBrowserStorage>(); + + services.AddSingleton<RoleManager>(); + services.AddSingleton<UserRoleManager>(); + + services.AddScoped<CustomAuthenticationStateProvider>(); + services.AddScoped<AuthenticationStateProvider>(static services => services.GetRequiredService<CustomAuthenticationStateProvider>()); + + services.AddAuthorization(ConfigureAuthorization); + services.AddSingleton<PermissionManager>(); + services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>(); + } + + public static void UsePhantomServices(this IApplicationBuilder application) { + application.UseAuthorization(); + } + + private static void ConfigureAuthorization(AuthorizationOptions o) { + foreach (var permission in Permission.All) { + o.AddPolicy(permission.Id, policy => policy.Requirements.Add(new PermissionBasedPolicyRequirement(permission))); + } + } +} diff --git a/Web/Phantom.Web.Services/Rpc/ControllerConnection.cs b/Web/Phantom.Web.Services/Rpc/ControllerConnection.cs new file mode 100644 index 0000000..a2e3d6f --- /dev/null +++ b/Web/Phantom.Web.Services/Rpc/ControllerConnection.cs @@ -0,0 +1,24 @@ +using Phantom.Common.Messages.Web; +using Phantom.Utils.Rpc; + +namespace Phantom.Web.Services.Rpc; + +public sealed class ControllerConnection { + private readonly RpcConnectionToServer<IMessageToControllerListener> connection; + + public ControllerConnection(RpcConnectionToServer<IMessageToControllerListener> connection) { + this.connection = connection; + } + + public Task Send<TMessage>(TMessage message) where TMessage : IMessageToController { + return connection.Send(message); + } + + public Task<TReply> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken = default) where TMessage : IMessageToController<TReply> { + return connection.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken); + } + + public Task<TReply> Send<TMessage, TReply>(TMessage message, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> { + return connection.Send<TMessage, TReply>(message, Timeout.InfiniteTimeSpan, waitForReplyCancellationToken); + } +} diff --git a/Web/Phantom.Web.Services/Rpc/MessageListener.cs b/Web/Phantom.Web.Services/Rpc/MessageListener.cs new file mode 100644 index 0000000..6b821f4 --- /dev/null +++ b/Web/Phantom.Web.Services/Rpc/MessageListener.cs @@ -0,0 +1,51 @@ +using Phantom.Common.Messages.Web; +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Common.Messages.Web.ToWeb; +using Phantom.Utils.Rpc; +using Phantom.Utils.Rpc.Message; +using Phantom.Utils.Tasks; +using Phantom.Web.Services.Agents; +using Phantom.Web.Services.Instances; + +namespace Phantom.Web.Services.Rpc; + +public sealed class MessageListener : IMessageToWebListener { + public TaskCompletionSource<bool> RegisterSuccessWaiter { get; } = AsyncTasks.CreateCompletionSource<bool>(); + + private readonly RpcConnectionToServer<IMessageToControllerListener> connection; + private readonly AgentManager agentManager; + private readonly InstanceManager instanceManager; + private readonly InstanceLogManager instanceLogManager; + + public MessageListener(RpcConnectionToServer<IMessageToControllerListener> connection, AgentManager agentManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager) { + this.connection = connection; + this.agentManager = agentManager; + this.instanceManager = instanceManager; + this.instanceLogManager = instanceLogManager; + } + + public Task<NoReply> HandleRegisterWebResult(RegisterWebResultMessage message) { + RegisterSuccessWaiter.TrySetResult(message.Success); + return Task.FromResult(NoReply.Instance); + } + + public Task<NoReply> HandleRefreshAgents(RefreshAgentsMessage message) { + agentManager.RefreshAgents(message.Agents); + return Task.FromResult(NoReply.Instance); + } + + public Task<NoReply> HandleRefreshInstances(RefreshInstancesMessage message) { + instanceManager.RefreshInstances(message.Instances); + return Task.FromResult(NoReply.Instance); + } + + public Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message) { + instanceLogManager.AddLines(message.InstanceGuid, message.Lines); + return Task.FromResult(NoReply.Instance); + } + + public Task<NoReply> HandleReply(ReplyMessage message) { + connection.Receive(message); + return Task.FromResult(NoReply.Instance); + } +} diff --git a/Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs b/Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs new file mode 100644 index 0000000..393265f --- /dev/null +++ b/Web/Phantom.Web.Services/Rpc/RpcClientRuntime.cs @@ -0,0 +1,27 @@ +using NetMQ; +using NetMQ.Sockets; +using Phantom.Common.Messages.Web; +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Utils.Rpc; +using Phantom.Utils.Rpc.Sockets; +using ILogger = Serilog.ILogger; + +namespace Phantom.Web.Services.Rpc; + +public sealed class RpcClientRuntime : RpcClientRuntime<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> { + public static Task Launch(RpcClientSocket<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> socket, IMessageToWebListener messageListener, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) { + return new RpcClientRuntime(socket, messageListener, disconnectSemaphore, receiveCancellationToken).Launch(); + } + + private RpcClientRuntime(RpcClientSocket<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> socket, IMessageToWebListener messageListener, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(socket, messageListener, disconnectSemaphore, receiveCancellationToken) {} + + protected override async Task Disconnect(ClientSocket socket, ILogger logger) { + var unregisterMessageBytes = WebMessageRegistries.ToController.Write(new UnregisterWebMessage()).ToArray(); + try { + await socket.SendAsync(unregisterMessageBytes).AsTask().WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } catch (TimeoutException) { + logger.Error("Timed out communicating web shutdown with the controller."); + } + } +} diff --git a/Web/Phantom.Web.Services/Users/AuditLogManager.cs b/Web/Phantom.Web.Services/Users/AuditLogManager.cs new file mode 100644 index 0000000..9f521e7 --- /dev/null +++ b/Web/Phantom.Web.Services/Users/AuditLogManager.cs @@ -0,0 +1,19 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.AuditLog; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Web.Services.Rpc; + +namespace Phantom.Web.Services.Users; + +public sealed class AuditLogManager { + private readonly ControllerConnection controllerConnection; + + public AuditLogManager(ControllerConnection controllerConnection) { + this.controllerConnection = controllerConnection; + } + + public Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) { + var message = new GetAuditLogMessage(count); + return controllerConnection.Send<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(message, cancellationToken); + } +} diff --git a/Web/Phantom.Web.Services/Users/RoleManager.cs b/Web/Phantom.Web.Services/Users/RoleManager.cs new file mode 100644 index 0000000..d54a724 --- /dev/null +++ b/Web/Phantom.Web.Services/Users/RoleManager.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Web.Services.Rpc; + +namespace Phantom.Web.Services.Users; + +public sealed class RoleManager { + private readonly ControllerConnection controllerConnection; + + public RoleManager(ControllerConnection controllerConnection) { + this.controllerConnection = controllerConnection; + } + + public Task<ImmutableArray<RoleInfo>> GetAll(CancellationToken cancellationToken) { + return controllerConnection.Send<GetRolesMessage, ImmutableArray<RoleInfo>>(new GetRolesMessage(), cancellationToken); + } +} diff --git a/Web/Phantom.Web.Services/Users/UserManager.cs b/Web/Phantom.Web.Services/Users/UserManager.cs new file mode 100644 index 0000000..eb0502a --- /dev/null +++ b/Web/Phantom.Web.Services/Users/UserManager.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Web.Services.Rpc; + +namespace Phantom.Web.Services.Users; + +public sealed class UserManager { + private readonly ControllerConnection controllerConnection; + + public UserManager(ControllerConnection controllerConnection) { + this.controllerConnection = controllerConnection; + } + + public Task<ImmutableArray<UserInfo>> GetAll(CancellationToken cancellationToken) { + return controllerConnection.Send<GetUsersMessage, ImmutableArray<UserInfo>>(new GetUsersMessage(), cancellationToken); + } + + public Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password, CancellationToken cancellationToken) { + return controllerConnection.Send<CreateUserMessage, CreateUserResult>(new CreateUserMessage(loggedInUserGuid, username, password), cancellationToken); + } + + public Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid, CancellationToken cancellationToken) { + return controllerConnection.Send<DeleteUserMessage, DeleteUserResult>(new DeleteUserMessage(loggedInUserGuid, userGuid), cancellationToken); + } +} diff --git a/Web/Phantom.Web.Services/Users/UserRoleManager.cs b/Web/Phantom.Web.Services/Users/UserRoleManager.cs new file mode 100644 index 0000000..06a5f7d --- /dev/null +++ b/Web/Phantom.Web.Services/Users/UserRoleManager.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; +using Phantom.Common.Data.Web.Users; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Web.Services.Rpc; + +namespace Phantom.Web.Services.Users; + +public sealed class UserRoleManager { + private readonly ControllerConnection controllerConnection; + + public UserRoleManager(ControllerConnection controllerConnection) { + this.controllerConnection = controllerConnection; + } + + public Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetUserRoles(ImmutableHashSet<Guid> userGuids, CancellationToken cancellationToken) { + return controllerConnection.Send<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(new GetUserRolesMessage(userGuids), cancellationToken); + } + + public async Task<ImmutableArray<Guid>> GetUserRoles(Guid userGuid, CancellationToken cancellationToken) { + return (await GetUserRoles(ImmutableHashSet.Create(userGuid), cancellationToken)).GetValueOrDefault(userGuid, ImmutableArray<Guid>.Empty); + } + + public Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids, CancellationToken cancellationToken) { + return controllerConnection.Send<ChangeUserRolesMessage, ChangeUserRolesResult>(new ChangeUserRolesMessage(loggedInUserGuid, subjectUserGuid, addToRoleGuids, removeFromRoleGuids), cancellationToken); + } +} diff --git a/Web/Phantom.Web/App.razor b/Web/Phantom.Web/App.razor index e3a2445..43fa75a 100644 --- a/Web/Phantom.Web/App.razor +++ b/Web/Phantom.Web/App.razor @@ -1,5 +1,4 @@ -@using Phantom.Web.Identity.Interfaces -@using Phantom.Web.Identity.Authentication +@using Phantom.Web.Services @inject INavigation Nav @inject NavigationManager NavigationManager @@ -8,14 +7,14 @@ <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <NotAuthorized> - @if (!PhantomLoginManager.IsAuthenticated(context.User)) { - var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/'); - Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true); - } - else { + @if (context.User.Identity is { IsAuthenticated: true }) { <h1>Forbidden</h1> <p role="alert">You do not have permission to visit this page.</p> } + else { + var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri).TrimEnd('/'); + Nav.NavigateTo("login" + QueryString.Create("return", returnUrl), forceLoad: true); + } </NotAuthorized> </AuthorizeRouteView> <FocusOnNavigate RouteData="@routeData" Selector="h1" /> diff --git a/Web/Phantom.Web/Base/LoginEvents.cs b/Web/Phantom.Web/Base/LoginEvents.cs deleted file mode 100644 index ce7212e..0000000 --- a/Web/Phantom.Web/Base/LoginEvents.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Phantom.Controller.Database.Entities; -using Phantom.Controller.Services.Audit; -using Phantom.Web.Identity.Interfaces; - -namespace Phantom.Web.Base; - -sealed class LoginEvents : ILoginEvents { - private readonly AuditLog auditLog; - - public LoginEvents(AuditLog auditLog) { - this.auditLog = auditLog; - } - - public void UserLoggedIn(UserEntity user) { - auditLog.AddUserLoggedInEvent(user); - } - - public void UserLoggedOut(Guid userGuid) { - auditLog.AddUserLoggedOutEvent(userGuid); - } -} diff --git a/Web/Phantom.Web/Base/Navigation.cs b/Web/Phantom.Web/Base/Navigation.cs index d6693db..f7c704d 100644 --- a/Web/Phantom.Web/Base/Navigation.cs +++ b/Web/Phantom.Web/Base/Navigation.cs @@ -1,7 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Web; using Microsoft.AspNetCore.Components; -using Phantom.Web.Identity.Interfaces; +using Microsoft.AspNetCore.Components.Routing; +using Phantom.Web.Services; namespace Phantom.Web.Base; @@ -27,7 +28,24 @@ sealed class Navigation : INavigation { return value != null; } - public void NavigateTo(string url, bool forceLoad = false) { - navigationManager.NavigateTo(BasePath + url, forceLoad); + public async Task NavigateTo(string url, bool forceLoad = false) { + var newPath = BasePath + url; + + var navigationTaskSource = new TaskCompletionSource(); + navigationManager.LocationChanged += NavigationManagerOnLocationChanged; + try { + navigationManager.NavigateTo(newPath, forceLoad); + await navigationTaskSource.Task.WaitAsync(TimeSpan.FromSeconds(10)); + } finally { + navigationManager.LocationChanged -= NavigationManagerOnLocationChanged; + } + + return; + + void NavigationManagerOnLocationChanged(object? sender, LocationChangedEventArgs e) { + if (Uri.TryCreate(e.Location, UriKind.Absolute, out var uri) && uri.AbsolutePath == newPath) { + navigationTaskSource.SetResult(); + } + } } } diff --git a/Web/Phantom.Web/Base/PhantomComponent.cs b/Web/Phantom.Web/Base/PhantomComponent.cs index 3e8b727..f401941 100644 --- a/Web/Phantom.Web/Base/PhantomComponent.cs +++ b/Web/Phantom.Web/Base/PhantomComponent.cs @@ -1,13 +1,14 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; +using Phantom.Common.Data.Web.Users; using Phantom.Common.Logging; -using Phantom.Web.Identity.Authorization; -using Phantom.Web.Identity.Data; +using Phantom.Web.Services.Authorization; using ILogger = Serilog.ILogger; +using UserInfo = Phantom.Web.Services.Authentication.UserInfo; namespace Phantom.Web.Base; -public abstract class PhantomComponent : ComponentBase { +public abstract class PhantomComponent : ComponentBase, IDisposable { private static readonly ILogger Logger = PhantomLogger.Create<PhantomComponent>(); [CascadingParameter] @@ -16,12 +17,30 @@ public abstract class PhantomComponent : ComponentBase { [Inject] public PermissionManager PermissionManager { get; set; } = null!; + private readonly CancellationTokenSource cancellationTokenSource = new (); + + protected CancellationToken CancellationToken => cancellationTokenSource.Token; + + protected async Task<Guid?> GetUserGuid() { + var authenticationState = await AuthenticationStateTask; + return UserInfo.TryGetGuid(authenticationState.User); + } + protected async Task<bool> CheckPermission(Permission permission) { var authenticationState = await AuthenticationStateTask; - return PermissionManager.CheckPermission(authenticationState.User, permission, refreshCache: true); + return PermissionManager.CheckPermission(authenticationState.User, permission); } protected void InvokeAsyncChecked(Func<Task> task) { InvokeAsync(task).ContinueWith(static t => Logger.Error(t.Exception, "Caught exception in async task."), TaskContinuationOptions.OnlyOnFaulted); } + + public void Dispose() { + cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + OnDisposed(); + GC.SuppressFinalize(this); + } + + protected virtual void OnDisposed() {} } diff --git a/Web/Phantom.Web/Configuration.cs b/Web/Phantom.Web/Configuration.cs index 00826ee..3ce1ceb 100644 --- a/Web/Phantom.Web/Configuration.cs +++ b/Web/Phantom.Web/Configuration.cs @@ -2,6 +2,6 @@ namespace Phantom.Web; -public sealed record Configuration(ILogger Logger, string Host, ushort Port, string BasePath, string KeyFolderPath, CancellationToken CancellationToken) { +sealed record Configuration(ILogger Logger, string Host, ushort Port, string BasePath, string DataProtectionKeyFolderPath, CancellationToken CancellationToken) { public string HttpUrl => "http://" + Host + ":" + Port; } diff --git a/Web/Phantom.Web/Layout/NavMenu.razor b/Web/Phantom.Web/Layout/NavMenu.razor index 2eda009..6d78ae4 100644 --- a/Web/Phantom.Web/Layout/NavMenu.razor +++ b/Web/Phantom.Web/Layout/NavMenu.razor @@ -1,4 +1,5 @@ -@using Phantom.Controller.Services +@using Phantom.Web.Services.Authorization +@using Phantom.Common.Data.Web.Users @inject ServiceConfiguration Configuration @inject PermissionManager PermissionManager diff --git a/Web/Phantom.Web/Layout/_Error.cshtml b/Web/Phantom.Web/Layout/_Error.cshtml index 0781ade..1e6e42f 100644 --- a/Web/Phantom.Web/Layout/_Error.cshtml +++ b/Web/Phantom.Web/Layout/_Error.cshtml @@ -1,5 +1,5 @@ @page -@using Phantom.Web.Identity.Interfaces +@using Phantom.Web.Services @model Phantom.Web.Layout.ErrorModel @inject INavigation Navigation diff --git a/Web/Phantom.Web/Layout/_Layout.cshtml b/Web/Phantom.Web/Layout/_Layout.cshtml index a6f59e9..6791c4d 100644 --- a/Web/Phantom.Web/Layout/_Layout.cshtml +++ b/Web/Phantom.Web/Layout/_Layout.cshtml @@ -1,4 +1,4 @@ -@using Phantom.Web.Identity.Interfaces +@using Phantom.Web.Services @namespace Phantom.Web.Layout @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @inject INavigation Navigation diff --git a/Web/Phantom.Web/Pages/Agents.razor b/Web/Phantom.Web/Pages/Agents.razor index 57c20dd..a5e80dc 100644 --- a/Web/Phantom.Web/Pages/Agents.razor +++ b/Web/Phantom.Web/Pages/Agents.razor @@ -1,7 +1,8 @@ @page "/agents" -@using Phantom.Controller.Services.Agents +@using Phantom.Common.Data.Web.Agent @using Phantom.Utils.Collections -@implements IDisposable +@using Phantom.Web.Services.Agents +@inherits PhantomComponent @inject AgentManager AgentManager <h1>Agents</h1> @@ -23,7 +24,7 @@ @foreach (var agent in agentTable) { var usedInstances = agent.Stats?.RunningInstanceCount; var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes; - + <tr> <td>@agent.Name</td> <td class="text-end"> @@ -74,7 +75,7 @@ @code { - private readonly Table<Agent, Guid> agentTable = new(); + private readonly Table<AgentWithStats, Guid> agentTable = new(); protected override void OnInitialized() { AgentManager.AgentsChanged.Subscribe(this, agents => { @@ -84,7 +85,7 @@ }); } - void IDisposable.Dispose() { + protected override void OnDisposed() { AgentManager.AgentsChanged.Unsubscribe(this); } diff --git a/Web/Phantom.Web/Pages/Audit.razor b/Web/Phantom.Web/Pages/Audit.razor index 41f570c..e334fa0 100644 --- a/Web/Phantom.Web/Pages/Audit.razor +++ b/Web/Phantom.Web/Pages/Audit.razor @@ -1,12 +1,12 @@ @page "/audit" @attribute [Authorize(Permission.ViewAuditPolicy)] -@using Phantom.Controller.Services.Audit -@using Phantom.Controller.Services.Instances -@using Phantom.Controller.Services.Users @using System.Collections.Immutable -@using Phantom.Controller.Database.Enums -@implements IDisposable -@inject AuditLog AuditLog +@using Phantom.Common.Data.Web.AuditLog +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Users +@using Phantom.Web.Services.Instances +@inherits PhantomComponent +@inject AuditLogManager AuditLogManager @inject InstanceManager InstanceManager @inject UserManager UserManager @@ -43,7 +43,7 @@ <code class="text-uppercase">@(logItem.SubjectId ?? "-")</code> </td> <td> - <code>@logItem.Data?.RootElement.ToString()</code> + <code>@logItem.JsonData</code> </td> </tr> } @@ -53,8 +53,8 @@ @code { private CancellationTokenSource? initializationCancellationTokenSource; - private AuditLogItem[] logItems = Array.Empty<AuditLogItem>(); - private Dictionary<Guid, string>? userNamesByGuid; + private ImmutableArray<AuditLogItem> logItems = ImmutableArray<AuditLogItem>.Empty; + private ImmutableDictionary<Guid, string>? userNamesByGuid; private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; protected override async Task OnInitializedAsync() { @@ -62,14 +62,14 @@ var cancellationToken = initializationCancellationTokenSource.Token; try { - logItems = await AuditLog.GetItems(50, cancellationToken); - userNamesByGuid = await UserManager.GetAllByGuid(static user => user.Name, cancellationToken); - instanceNamesByGuid = InstanceManager.GetInstanceNames(); + logItems = await AuditLogManager.GetMostRecentItems(50, cancellationToken); + userNamesByGuid = (await UserManager.GetAll(cancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name); + instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid, static instance => instance.Configuration.InstanceName); } finally { initializationCancellationTokenSource.Dispose(); } } - + private string? GetSubjectName(AuditLogSubjectType type, string id) { return type switch { AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, @@ -78,7 +78,7 @@ }; } - public void Dispose() { + protected override void OnDisposed() { try { initializationCancellationTokenSource?.Cancel(); } catch (ObjectDisposedException) {} diff --git a/Web/Phantom.Web/Pages/Events.razor b/Web/Phantom.Web/Pages/Events.razor index d6f48a3..41527b7 100644 --- a/Web/Phantom.Web/Pages/Events.razor +++ b/Web/Phantom.Web/Pages/Events.razor @@ -1,13 +1,14 @@ @page "/events" @attribute [Authorize(Permission.ViewEventsPolicy)] @using System.Collections.Immutable -@using Phantom.Controller.Services.Events -@using Phantom.Controller.Services.Instances -@using Phantom.Controller.Database.Enums -@using Phantom.Controller.Services.Agents -@implements IDisposable +@using Phantom.Common.Data.Web.EventLog +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Agents +@using Phantom.Web.Services.Events +@using Phantom.Web.Services.Instances +@inherits PhantomComponent @inject AgentManager AgentManager -@inject EventLog EventLog +@inject EventLogManager EventLogManager @inject InstanceManager InstanceManager <h1>Event Log</h1> @@ -48,7 +49,7 @@ <code class="text-uppercase">@logItem.SubjectId</code> </td> <td> - <code>@logItem.Data?.RootElement.ToString()</code> + <code>@logItem.JsonData</code> </td> </tr> } @@ -58,7 +59,7 @@ @code { private CancellationTokenSource? initializationCancellationTokenSource; - private EventLogItem[] logItems = Array.Empty<EventLogItem>(); + private ImmutableArray<EventLogItem> logItems = ImmutableArray<EventLogItem>.Empty; private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty; private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; @@ -67,9 +68,9 @@ var cancellationToken = initializationCancellationTokenSource.Token; try { - logItems = await EventLog.GetItems(50, cancellationToken); - agentNamesByGuid = AgentManager.GetAgents().ToImmutableDictionary(static kvp => kvp.Key, static kvp => kvp.Value.Name); - instanceNamesByGuid = InstanceManager.GetInstanceNames(); + logItems = await EventLogManager.GetMostRecentItems(50, cancellationToken); + agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.Guid, static kvp => kvp.Name); + instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.Configuration.InstanceGuid, static instance => instance.Configuration.InstanceName); } finally { initializationCancellationTokenSource.Dispose(); } @@ -78,7 +79,7 @@ private string GetAgentName(Guid agentGuid) { return agentNamesByGuid.TryGetValue(agentGuid, out var name) ? name : "?"; } - + private string? GetSubjectName(EventLogSubjectType type, string id) { return type switch { EventLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, @@ -86,7 +87,7 @@ }; } - public void Dispose() { + protected override void OnDisposed() { try { initializationCancellationTokenSource?.Cancel(); } catch (ObjectDisposedException) {} diff --git a/Web/Phantom.Web/Pages/InstanceCreate.razor b/Web/Phantom.Web/Pages/InstanceCreate.razor index fc38613..c1baee6 100644 --- a/Web/Phantom.Web/Pages/InstanceCreate.razor +++ b/Web/Phantom.Web/Pages/InstanceCreate.razor @@ -1,4 +1,5 @@ @page "/instances/create" +@using Phantom.Common.Data.Web.Users @attribute [Authorize(Permission.CreateInstancesPolicy)] <h1>New Instance</h1> diff --git a/Web/Phantom.Web/Pages/InstanceDetail.razor b/Web/Phantom.Web/Pages/InstanceDetail.razor index 3cd0f06..23b5991 100644 --- a/Web/Phantom.Web/Pages/InstanceDetail.razor +++ b/Web/Phantom.Web/Pages/InstanceDetail.razor @@ -1,13 +1,13 @@ @page "/instances/{InstanceGuid:guid}" @attribute [Authorize(Permission.ViewInstancesPolicy)] -@inherits PhantomComponent @using Phantom.Common.Data.Instance @using Phantom.Common.Data.Replies -@using Phantom.Controller.Services.Audit -@using Phantom.Controller.Services.Instances -@implements IDisposable +@using Phantom.Common.Data.Web.Instance +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Instances +@using Phantom.Web.Services.Authorization +@inherits PhantomComponent @inject InstanceManager InstanceManager -@inject AuditLog AuditLog @if (Instance == null) { <h1>Instance Not Found</h1> @@ -46,7 +46,7 @@ else { @code { [Parameter] - public Guid InstanceGuid { get; set; } + public Guid InstanceGuid { get; init; } private string? lastError = null; private bool isLaunchingInstance = false; @@ -68,16 +68,14 @@ else { lastError = null; try { - if (!await CheckPermission(Permission.ControlInstances)) { + var loggedInUserGuid = await GetUserGuid(); + if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) { lastError = "You do not have permission to launch instances."; return; } - var result = await InstanceManager.LaunchInstance(InstanceGuid); - if (result.Is(LaunchInstanceResult.LaunchInitiated)) { - await AuditLog.AddInstanceLaunchedEvent(InstanceGuid); - } - else { + var result = await InstanceManager.LaunchInstance(loggedInUserGuid.Value, InstanceGuid, CancellationToken); + if (!result.Is(LaunchInstanceResult.LaunchInitiated)) { lastError = result.ToSentence(Messages.ToSentence); } } finally { @@ -85,7 +83,7 @@ else { } } - public void Dispose() { + protected override void OnDisposed() { InstanceManager.InstancesChanged.Unsubscribe(this); } diff --git a/Web/Phantom.Web/Pages/InstanceEdit.razor b/Web/Phantom.Web/Pages/InstanceEdit.razor index f8c142b..270f25b 100644 --- a/Web/Phantom.Web/Pages/InstanceEdit.razor +++ b/Web/Phantom.Web/Pages/InstanceEdit.razor @@ -1,7 +1,8 @@ @page "/instances/{InstanceGuid:guid}/edit" @attribute [Authorize(Permission.CreateInstancesPolicy)] @using Phantom.Common.Data.Instance -@using Phantom.Controller.Services.Instances +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Instances @inherits PhantomComponent @inject InstanceManager InstanceManager @@ -17,12 +18,12 @@ else { @code { [Parameter] - public Guid InstanceGuid { get; set; } + public Guid InstanceGuid { get; init; } private InstanceConfiguration? InstanceConfiguration { get; set; } protected override void OnInitialized() { - InstanceConfiguration = InstanceManager.GetInstanceConfiguration(InstanceGuid); + InstanceConfiguration = InstanceManager.GetByGuid(InstanceGuid)?.Configuration; } } diff --git a/Web/Phantom.Web/Pages/Instances.razor b/Web/Phantom.Web/Pages/Instances.razor index e47b9a0..c4ad3c9 100644 --- a/Web/Phantom.Web/Pages/Instances.razor +++ b/Web/Phantom.Web/Pages/Instances.razor @@ -1,9 +1,12 @@ @page "/instances" @attribute [Authorize(Permission.ViewInstancesPolicy)] @using System.Collections.Immutable -@using Phantom.Controller.Services.Instances -@using Phantom.Controller.Services.Agents -@implements IDisposable +@using Phantom.Common.Data.Web.Instance +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Agents +@using Phantom.Web.Services.Authorization +@using Phantom.Web.Services.Instances +@inherits PhantomComponent @inject AgentManager AgentManager @inject InstanceManager InstanceManager @@ -30,7 +33,7 @@ @if (!instances.IsEmpty) { <tbody> @foreach (var (configuration, status, _) in instances) { - var agentName = agentNames.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty; + var agentName = agentNamesByGuid.TryGetValue(configuration.AgentGuid, out var name) ? name : string.Empty; var instanceGuid = configuration.InstanceGuid.ToString(); <tr> <td>@agentName</td> @@ -71,22 +74,25 @@ @code { - private ImmutableDictionary<Guid, string> agentNames = ImmutableDictionary<Guid, string>.Empty; + private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty; private ImmutableArray<Instance> instances = ImmutableArray<Instance>.Empty; protected override void OnInitialized() { AgentManager.AgentsChanged.Subscribe(this, agents => { - this.agentNames = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name); + this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.Guid, static agent => agent.Name); InvokeAsync(StateHasChanged); }); InstanceManager.InstancesChanged.Subscribe(this, instances => { - this.instances = instances.Values.OrderBy(instance => agentNames.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty).ThenBy(static instance => instance.Configuration.InstanceName).ToImmutableArray(); + this.instances = instances.Values + .OrderBy(instance => agentNamesByGuid.TryGetValue(instance.Configuration.AgentGuid, out var agentName) ? agentName : string.Empty) + .ThenBy(static instance => instance.Configuration.InstanceName) + .ToImmutableArray(); InvokeAsync(StateHasChanged); }); } - void IDisposable.Dispose() { + protected override void OnDisposed() { AgentManager.AgentsChanged.Unsubscribe(this); InstanceManager.InstancesChanged.Unsubscribe(this); } diff --git a/Web/Phantom.Web/Pages/Login.razor b/Web/Phantom.Web/Pages/Login.razor index f1748e6..87001ed 100644 --- a/Web/Phantom.Web/Pages/Login.razor +++ b/Web/Phantom.Web/Pages/Login.razor @@ -1,10 +1,10 @@ @page "/login" -@using Phantom.Web.Identity.Interfaces -@using Phantom.Web.Identity.Authentication +@using Phantom.Web.Services +@using Phantom.Web.Services.Authentication @using System.ComponentModel.DataAnnotations @attribute [AllowAnonymous] @inject INavigation Navigation -@inject PhantomLoginManager LoginManager +@inject UserLoginManager LoginManager <h1>Login</h1> @@ -51,7 +51,7 @@ string? returnUrl = Navigation.GetQueryParameter("return", out var url) ? url : null; - if (!await LoginManager.SignIn(form.Username, form.Password, returnUrl)) { + if (!await LoginManager.LogIn(form.Username, form.Password, returnUrl)) { form.SubmitModel.StopSubmitting("Invalid username or password."); } } diff --git a/Web/Phantom.Web/Pages/Logout.razor b/Web/Phantom.Web/Pages/Logout.razor new file mode 100644 index 0000000..d2f277c --- /dev/null +++ b/Web/Phantom.Web/Pages/Logout.razor @@ -0,0 +1,11 @@ +@page "/logout" +@using Phantom.Web.Services.Authentication +@inject UserLoginManager LoginManager + +@code { + + protected override Task OnInitializedAsync() { + return LoginManager.LogOut(); + } + +} diff --git a/Web/Phantom.Web/Pages/Setup.razor b/Web/Phantom.Web/Pages/Setup.razor index cb01807..de89aa6 100644 --- a/Web/Phantom.Web/Pages/Setup.razor +++ b/Web/Phantom.Web/Pages/Setup.razor @@ -1,20 +1,17 @@ @page "/setup" -@using Phantom.Controller.Services.Users -@using Phantom.Utils.Cryptography @using Phantom.Utils.Tasks -@using Phantom.Controller.Database.Entities -@using Phantom.Controller.Services -@using Phantom.Controller.Services.Audit -@using Phantom.Web.Identity.Authentication +@using Phantom.Web.Services.Authentication +@using Phantom.Web.Services.Rpc @using System.ComponentModel.DataAnnotations +@using Phantom.Utils.Cryptography @using System.Security.Cryptography +@using Phantom.Common.Messages.Web.ToController +@using Phantom.Common.Data.Web.Users +@using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults @attribute [AllowAnonymous] @inject ServiceConfiguration ServiceConfiguration -@inject PhantomLoginManager LoginManager -@inject UserManager UserManager -@inject RoleManager RoleManager -@inject UserRoleManager UserRoleManager -@inject AuditLog AuditLog +@inject UserLoginManager LoginManager +@inject ControllerConnection ControllerConnection <h1>Administrator Setup</h1> @@ -72,7 +69,7 @@ return; } - var signInResult = await LoginManager.SignIn(form.Username, form.Password); + var signInResult = await LoginManager.LogIn(form.Username, form.Password); if (!signInResult) { form.SubmitModel.StopSubmitting("Error logging in."); } @@ -90,45 +87,15 @@ } private async Task<Result<string>> CreateOrUpdateAdministrator() { - var existingUser = await UserManager.GetByName(form.Username); - return existingUser == null ? await CreateAdministrator() : await UpdateAdministrator(existingUser); - } - - private async Task<Result<string>> CreateAdministrator() { - var administratorRole = await RoleManager.GetByGuid(Role.Administrator.Guid); - if (administratorRole == null) { - return Result.Fail("Administrator role not found."); - } - - switch (await UserManager.CreateUser(form.Username, form.Password)) { - case Result<UserEntity, AddUserError>.Ok ok: - var administratorUser = ok.Value; - await AuditLog.AddAdministratorUserCreatedEvent(administratorUser); - - if (!await UserRoleManager.Add(administratorUser, administratorRole)) { - return Result.Fail("Could not assign administrator role to user."); - } - - return Result.Ok<string>(); - - case Result<UserEntity, AddUserError>.Fail fail: - return Result.Fail(fail.Error.ToSentences("\n")); - } - - return Result.Fail("Unknown error."); - } - - private async Task<Result<string>> UpdateAdministrator(UserEntity existingUser) { - switch (await UserManager.SetUserPassword(existingUser.UserGuid, form.Password)) { - case Result<SetUserPasswordError>.Ok: - await AuditLog.AddAdministratorUserModifiedEvent(existingUser); - return Result.Ok<string>(); - - case Result<SetUserPasswordError>.Fail fail: - return Result.Fail(fail.Error.ToSentences("\n")); - } - - return Result.Fail("Unknown error."); + var reply = await ControllerConnection.Send<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(new CreateOrUpdateAdministratorUserMessage(form.Username, form.Password), Timeout.InfiniteTimeSpan); + return reply switch { + Success => Result.Ok<string>(), + CreationFailed fail => fail.Error.ToSentences("\n"), + UpdatingFailed fail => fail.Error.ToSentences("\n"), + AddingToRoleFailed => "Could not assign administrator role to user.", + null => "Timed out.", + _ => "Unknown error." + }; } } diff --git a/Web/Phantom.Web/Pages/Users.razor b/Web/Phantom.Web/Pages/Users.razor index a4cca10..45f59bc 100644 --- a/Web/Phantom.Web/Pages/Users.razor +++ b/Web/Phantom.Web/Pages/Users.razor @@ -1,11 +1,13 @@ @page "/users" -@using Phantom.Controller.Database.Entities -@using Phantom.Controller.Services.Users -@using System.Collections.Immutable @attribute [Authorize(Permission.ViewUsersPolicy)] +@using System.Collections.Immutable +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Users +@using Phantom.Web.Services.Authorization +@inherits PhantomComponent @inject UserManager UserManager +@inject RoleManager RoleManager @inject UserRoleManager UserRoleManager -@inject PermissionManager PermissionManager <h1>Users</h1> @@ -28,12 +30,11 @@ </tr> </thead> <tbody> - @{ var myUserId = UserManager.GetAuthenticatedUserId(context.User); } @foreach (var user in allUsers) { - var isMe = myUserId == user.UserGuid; + var isMe = me == user.Guid; <tr> <td> - <code class="text-uppercase">@user.UserGuid</code> + <code class="text-uppercase">@user.Guid</code> </td> @if (isMe) { <td class="fw-semibold">@user.Name</td> @@ -41,7 +42,7 @@ else { <td>@user.Name</td> } - <td>@(userGuidToRoleDescription.TryGetValue(user.UserGuid, out var roles) ? roles : "?")</td> + <td>@(userGuidToRoleDescription.TryGetValue(user.Guid, out var roles) ? roles : "?")</td> @if (canEdit) { <td> @if (!isMe) { @@ -65,46 +66,53 @@ @code { - private ImmutableArray<UserEntity> allUsers = ImmutableArray<UserEntity>.Empty; + private Guid? me = Guid.Empty; + private ImmutableArray<UserInfo> allUsers = ImmutableArray<UserInfo>.Empty; + private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty; private readonly Dictionary<Guid, string> userGuidToRoleDescription = new(); private UserRolesDialog userRolesDialog = null!; private UserDeleteDialog userDeleteDialog = null!; protected override async Task OnInitializedAsync() { - var unsortedUsers = await UserManager.GetAll(); - allUsers = unsortedUsers.Sort(static (a, b) => a.Name.CompareTo(b.Name)); + me = await GetUserGuid(); + + allUsers = (await UserManager.GetAll(CancellationToken)).Sort(static (a, b) => a.Name.CompareTo(b.Name)); + allRolesByGuid = (await RoleManager.GetAll(CancellationToken)).ToImmutableDictionary(static role => role.Guid, static role => role); + + var allUserGuids = allUsers + .Select(static user => user.Guid) + .ToImmutableHashSet(); - foreach (var (userGuid, roles) in await UserRoleManager.GetAllByUserGuid()) { - userGuidToRoleDescription[userGuid] = StringifyRoles(roles); - } - - foreach (var user in allUsers) { - await RefreshUserRoles(user); + foreach (var (userGuid, roleGuids) in await UserRoleManager.GetUserRoles(allUserGuids, CancellationToken)) { + userGuidToRoleDescription[userGuid] = StringifyRoles(roleGuids); } } - private async Task RefreshUserRoles(UserEntity user) { - var roles = await UserRoleManager.GetUserRoles(user); - userGuidToRoleDescription[user.UserGuid] = StringifyRoles(roles); + private async Task RefreshUserRoles(UserInfo user) { + userGuidToRoleDescription[user.Guid] = StringifyRoles(await UserRoleManager.GetUserRoles(user.Guid, CancellationToken)); } - private static string StringifyRoles(ImmutableArray<RoleEntity> roles) { - return roles.IsEmpty ? "-" : string.Join(", ", roles.Select(static role => role.Name)); + private string StringifyRoles(ImmutableArray<Guid> roleGuids) { + return roleGuids.IsEmpty ? "-" : string.Join(", ", roleGuids.Select(StringifyRole)); } - private Task OnUserAdded(UserEntity user) { + private string StringifyRole(Guid role) { + return allRolesByGuid.TryGetValue(role, out var roleInfo) ? roleInfo.Name : "?"; + } + + private Task OnUserAdded(UserInfo user) { allUsers = allUsers.Add(user); return RefreshUserRoles(user); } - private Task OnUserRolesChanged(UserEntity user) { + private Task OnUserRolesChanged(UserInfo user) { return RefreshUserRoles(user); } - private void OnUserDeleted(UserEntity user) { + private void OnUserDeleted(UserInfo user) { allUsers = allUsers.Remove(user); - userGuidToRoleDescription.Remove(user.UserGuid); + userGuidToRoleDescription.Remove(user.Guid); } } diff --git a/Web/Phantom.Web/Phantom.Web.csproj b/Web/Phantom.Web/Phantom.Web.csproj index f37ccd2..89bf413 100644 --- a/Web/Phantom.Web/Phantom.Web.csproj +++ b/Web/Phantom.Web/Phantom.Web.csproj @@ -20,8 +20,9 @@ </ItemGroup> <ItemGroup> + <ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" /> <ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" /> - <ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" /> + <ProjectReference Include="..\Phantom.Web.Services\Phantom.Web.Services.csproj" /> </ItemGroup> </Project> diff --git a/Web/Phantom.Web/Program.cs b/Web/Phantom.Web/Program.cs new file mode 100644 index 0000000..7def433 --- /dev/null +++ b/Web/Phantom.Web/Program.cs @@ -0,0 +1,107 @@ +using System.Reflection; +using NetMQ; +using Phantom.Common.Logging; +using Phantom.Common.Messages.Web; +using Phantom.Common.Messages.Web.ToController; +using Phantom.Utils.Cryptography; +using Phantom.Utils.IO; +using Phantom.Utils.Rpc; +using Phantom.Utils.Rpc.Sockets; +using Phantom.Utils.Runtime; +using Phantom.Utils.Tasks; +using Phantom.Web; +using Phantom.Web.Services.Rpc; + +var shutdownCancellationTokenSource = new CancellationTokenSource(); +var shutdownCancellationToken = shutdownCancellationTokenSource.Token; + +PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, 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 (controllerHost, controllerPort, webKeyToken, webKeyFilePath, webServerHost, webServerPort, webBasePath) = Variables.LoadOrStop(); + + var webKey = await WebKey.Load(webKeyToken, webKeyFilePath); + if (webKey == null) { + return 1; + } + + string dataProtectionKeysPath = Path.GetFullPath("./keys"); + CreateFolderOrStop(dataProtectionKeysPath, Chmod.URWX); + + var (controllerCertificate, webToken) = webKey.Value; + + var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), controllerHost, controllerPort, controllerCertificate); + var rpcSocket = RpcClientSocket.Connect(rpcConfiguration, WebMessageRegistries.Definitions, new RegisterWebMessage(webToken)); + + var configuration = new Configuration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, dataProtectionKeysPath, shutdownCancellationToken); + var administratorToken = TokenGenerator.Create(60); + + var taskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Web")); + var serviceConfiguration = new ServiceConfiguration(fullVersion, TokenGenerator.GetBytesOrThrow(administratorToken), shutdownCancellationToken); + var webApplication = WebLauncher.CreateApplication(configuration, taskManager, serviceConfiguration, rpcSocket.Connection); + + MessageListener messageListener; + await using (var scope = webApplication.Services.CreateAsyncScope()) { + messageListener = scope.ServiceProvider.GetRequiredService<MessageListener>(); + } + + var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1); + var rpcTask = RpcClientRuntime.Launch(rpcSocket, messageListener, rpcDisconnectSemaphore, shutdownCancellationToken); + try { + PhantomLogger.Root.Information("Registering with the controller..."); + if (await messageListener.RegisterSuccessWaiter.Task) { + PhantomLogger.Root.Information("Successfully registered with the controller."); + } + else { + PhantomLogger.Root.Fatal("Failed to register with the controller."); + return 1; + } + + PhantomLogger.Root.InformationHeading("Launching Phantom Panel web..."); + PhantomLogger.Root.Information("Your administrator token is: {AdministratorToken}", administratorToken); + PhantomLogger.Root.Information("For administrator setup, visit: {HttpUrl}{SetupPath}", configuration.HttpUrl, configuration.BasePath + "setup"); + + await WebLauncher.Launch(configuration, webApplication); + } finally { + shutdownCancellationTokenSource.Cancel(); + await taskManager.Stop(); + + rpcDisconnectSemaphore.Release(); + await rpcTask; + rpcDisconnectSemaphore.Dispose(); + + NetMQConfig.Cleanup(); + } + + 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 { + shutdownCancellationTokenSource.Dispose(); + + PhantomLogger.Root.Information("Bye!"); + PhantomLogger.Dispose(); +} diff --git a/Web/Phantom.Web/ServiceConfiguration.cs b/Web/Phantom.Web/ServiceConfiguration.cs new file mode 100644 index 0000000..10221ca --- /dev/null +++ b/Web/Phantom.Web/ServiceConfiguration.cs @@ -0,0 +1,7 @@ +namespace Phantom.Web; + +public sealed record ServiceConfiguration( + string Version, + byte[] AdministratorToken, + CancellationToken CancellationToken +); diff --git a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor index 8006f62..2ae7d6c 100644 --- a/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor +++ b/Web/Phantom.Web/Shared/InstanceAddOrEditForm.razor @@ -1,29 +1,32 @@ -@using System.Collections.Immutable +@using Phantom.Web.Components.Utils +@using System.Collections.Immutable @using System.ComponentModel.DataAnnotations @using System.Diagnostics.CodeAnalysis -@using Phantom.Controller.Minecraft -@using Phantom.Controller.Services.Agents -@using Phantom.Controller.Services.Audit -@using Phantom.Controller.Services.Instances -@using Phantom.Web.Components.Utils @using Phantom.Common.Data.Minecraft +@using Phantom.Common.Data.Web.Agent +@using Phantom.Common.Data.Web.Instance +@using Phantom.Common.Data.Web.Minecraft +@using Phantom.Common.Data.Web.Users +@using Phantom.Common.Messages.Web.ToController @using Phantom.Common.Data.Instance @using Phantom.Common.Data.Java @using Phantom.Common.Data -@using Phantom.Web.Identity.Interfaces +@using Phantom.Web.Services +@using Phantom.Web.Services.Agents +@using Phantom.Web.Services.Instances +@using Phantom.Web.Services.Rpc +@inherits PhantomComponent @inject INavigation Nav -@inject MinecraftVersions MinecraftVersions +@inject ControllerConnection ControllerConnection @inject AgentManager AgentManager -@inject AgentJavaRuntimesManager AgentJavaRuntimesManager @inject InstanceManager InstanceManager -@inject AuditLog AuditLog <Form Model="form" OnSubmit="AddOrEditInstance"> @{ var selectedAgent = form.SelectedAgent; } <div class="row"> <div class="col-xl-7 mb-3"> @{ - static RenderFragment GetAgentOption(Agent agent) { + static RenderFragment GetAgentOption(AgentWithStats agent) { return @<option value="@agent.Guid"> @agent.Name • @@ -36,14 +39,14 @@ @if (EditedInstanceConfiguration == null) { <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid"> <option value="" selected>Select which agent will run the instance...</option> - @foreach (var agent in form.AgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) { + @foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.IsOnline).OrderBy(static agent => agent.Name)) { @GetAgentOption(agent) } </FormSelectInput> } else { <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid" disabled="true"> - @if (form.SelectedAgentGuid is {} guid && form.AgentsByGuid.TryGetValue(guid, out var agent)) { + @if (form.SelectedAgentGuid is {} guid && allAgentsByGuid.TryGetValue(guid, out var agent)) { @GetAgentOption(agent) } </FormSelectInput> @@ -162,23 +165,25 @@ @code { [Parameter, EditorRequired] - public InstanceConfiguration? EditedInstanceConfiguration { get; set; } + public InstanceConfiguration? EditedInstanceConfiguration { get; init; } private ConfigureInstanceFormModel form = null!; + private ImmutableDictionary<Guid, AgentWithStats> allAgentsByGuid = ImmutableDictionary<Guid, AgentWithStats>.Empty; + private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> allAgentJavaRuntimes = ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>.Empty; + private MinecraftVersionType minecraftVersionType = MinecraftVersionType.Release; + private ImmutableArray<MinecraftVersion> allMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty; private ImmutableArray<MinecraftVersion> availableMinecraftVersions = ImmutableArray<MinecraftVersion>.Empty; private bool IsSubmittable => form.SelectedAgentGuid != null && !form.EditContext.GetValidationMessages(form.EditContext.Field(nameof(ConfigureInstanceFormModel.SelectedAgentGuid))).Any(); private sealed class ConfigureInstanceFormModel : FormModel { - public ImmutableDictionary<Guid, Agent> AgentsByGuid { get; } - private readonly ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> javaRuntimesByAgentGuid; + private readonly InstanceAddOrEditForm page; private readonly RamAllocationUnits? editedInstanceRamAllocation; - public ConfigureInstanceFormModel(AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, RamAllocationUnits? editedInstanceRamAllocation) { - this.AgentsByGuid = agentManager.GetAgents().ToImmutableDictionary(); - this.javaRuntimesByAgentGuid = agentJavaRuntimesManager.All; + public ConfigureInstanceFormModel(InstanceAddOrEditForm page, RamAllocationUnits? editedInstanceRamAllocation) { + this.page = page; this.editedInstanceRamAllocation = editedInstanceRamAllocation; } @@ -192,14 +197,14 @@ } } - private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out Agent? agent) { - return TryGet(AgentsByGuid, agentGuid, out agent); + private bool TryGetAgent(Guid? agentGuid, [NotNullWhen(true)] out AgentWithStats? agent) { + return TryGet(page.allAgentsByGuid, agentGuid, out agent); } - public Agent? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null; - - public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(javaRuntimesByAgentGuid, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty; - + public AgentWithStats? SelectedAgent => TryGetAgent(SelectedAgentGuid, out var agent) ? agent : null; + + public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty; + public ushort MaximumMemoryUnits => SelectedAgent?.MaxMemory.RawValue ?? 0; public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits); private ushort selectedMemoryUnits = 4; @@ -265,8 +270,17 @@ } protected override void OnInitialized() { - form = new ConfigureInstanceFormModel(AgentManager, AgentJavaRuntimesManager, EditedInstanceConfiguration?.MemoryAllocation); + form = new ConfigureInstanceFormModel(this, EditedInstanceConfiguration?.MemoryAllocation); + } + + protected override async Task OnInitializedAsync() { + var agentJavaRuntimesTask = ControllerConnection.Send<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(new GetAgentJavaRuntimesMessage(), TimeSpan.FromSeconds(30)); + var minecraftVersionsTask = ControllerConnection.Send<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(new GetMinecraftVersionsMessage(), TimeSpan.FromSeconds(30)); + allAgentsByGuid = AgentManager.ToDictionaryByGuid(); + allAgentJavaRuntimes = await agentJavaRuntimesTask; + allMinecraftVersions = await minecraftVersionsTask; + if (EditedInstanceConfiguration != null) { form.SelectedAgentGuid = EditedInstanceConfiguration.AgentGuid; form.InstanceName = EditedInstanceConfiguration.InstanceName; @@ -277,28 +291,21 @@ form.MemoryUnits = EditedInstanceConfiguration.MemoryAllocation.RawValue; form.JavaRuntimeGuid = EditedInstanceConfiguration.JavaRuntimeGuid; form.JvmArguments = JvmArgumentsHelper.Join(EditedInstanceConfiguration.JvmArguments); + + minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType; } - + form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.MemoryUnits)); form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.JavaRuntimeGuid)); form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.ServerPort)); form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.SelectedAgentGuid), revalidated: nameof(ConfigureInstanceFormModel.RconPort)); form.EditContext.RevalidateWhenFieldChanges(tracked: nameof(ConfigureInstanceFormModel.ServerPort), revalidated: nameof(ConfigureInstanceFormModel.RconPort)); + + SetMinecraftVersionType(minecraftVersionType); } - protected override async Task OnInitializedAsync() { - if (EditedInstanceConfiguration != null) { - var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None); - minecraftVersionType = allMinecraftVersions.FirstOrDefault(version => version.Id == EditedInstanceConfiguration.MinecraftVersion)?.Type ?? minecraftVersionType; - } - - await SetMinecraftVersionType(minecraftVersionType); - } - - private async Task SetMinecraftVersionType(MinecraftVersionType type) { + private void SetMinecraftVersionType(MinecraftVersionType type) { minecraftVersionType = type; - - var allMinecraftVersions = await MinecraftVersions.GetVersions(CancellationToken.None); availableMinecraftVersions = allMinecraftVersions.Where(version => version.Type == type).ToImmutableArray(); if (!availableMinecraftVersions.IsEmpty && !availableMinecraftVersions.Any(version => version.Id == form.MinecraftVersion)) { @@ -314,6 +321,12 @@ await form.SubmitModel.StartSubmitting(); + var loggedInUserGuid = await GetUserGuid(); + if (loggedInUserGuid == null || !await CheckPermission(Permission.CreateInstances)) { + form.SubmitModel.StopSubmitting("You do not have permission to edit instances."); + return; + } + var instance = new InstanceConfiguration( EditedInstanceConfiguration?.AgentGuid ?? selectedAgent.Guid, EditedInstanceConfiguration?.InstanceGuid ?? Guid.NewGuid(), @@ -327,14 +340,13 @@ JvmArgumentsHelper.Split(form.JvmArguments) ); - var result = await InstanceManager.AddOrEditInstance(instance); - if (result.Is(AddOrEditInstanceResult.Success)) { - await (EditedInstanceConfiguration == null ? AuditLog.AddInstanceCreatedEvent(instance.InstanceGuid) : AuditLog.AddInstanceEditedEvent(instance.InstanceGuid)); - Nav.NavigateTo("instances/" + instance.InstanceGuid); + var result = await InstanceManager.CreateOrUpdateInstance(loggedInUserGuid.Value, instance, CancellationToken); + if (result.Is(CreateOrUpdateInstanceResult.Success)) { + await Nav.NavigateTo("instances/" + instance.InstanceGuid); } else { - form.SubmitModel.StopSubmitting(result.ToSentence(AddOrEditInstanceResultExtensions.ToSentence)); + form.SubmitModel.StopSubmitting(result.ToSentence(CreateOrUpdateInstanceResultExtensions.ToSentence)); } } - + } diff --git a/Web/Phantom.Web/Shared/InstanceCommandInput.razor b/Web/Phantom.Web/Shared/InstanceCommandInput.razor index b7918b2..4303fa7 100644 --- a/Web/Phantom.Web/Shared/InstanceCommandInput.razor +++ b/Web/Phantom.Web/Shared/InstanceCommandInput.razor @@ -1,9 +1,8 @@ -@using Phantom.Controller.Services.Instances -@using Phantom.Controller.Services.Audit +@using Phantom.Web.Services.Instances +@using Phantom.Common.Data.Web.Users @using Phantom.Common.Data.Replies @inherits PhantomComponent @inject InstanceManager InstanceManager -@inject AuditLog AuditLog <Form Model="form" OnSubmit="ExecuteCommand"> <label for="command-input" class="form-label">Instance Name</label> @@ -16,7 +15,7 @@ </Form> @code { - + [Parameter] public Guid InstanceGuid { get; set; } @@ -24,24 +23,24 @@ public bool Disabled { get; set; } private readonly SendCommandFormModel form = new (); - + private sealed class SendCommandFormModel : FormModel { public string Command { get; set; } = string.Empty; } - + private ElementReference commandInputElement; private async Task ExecuteCommand(EditContext context) { await form.SubmitModel.StartSubmitting(); - if (!await CheckPermission(Permission.ControlInstances)) { + var loggedInUserGuid = await GetUserGuid(); + if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) { form.SubmitModel.StopSubmitting("You do not have permission to execute commands."); return; } - var result = await InstanceManager.SendCommand(InstanceGuid, form.Command); + var result = await InstanceManager.SendCommandToInstance(loggedInUserGuid.Value, InstanceGuid, form.Command, CancellationToken); if (result.Is(SendCommandToInstanceResult.Success)) { - await AuditLog.AddInstanceCommandExecutedEvent(InstanceGuid, form.Command); form.Command = string.Empty; form.SubmitModel.StopSubmitting(); } @@ -51,5 +50,5 @@ await commandInputElement.FocusAsync(preventScroll: true); } - + } diff --git a/Web/Phantom.Web/Shared/InstanceLog.razor b/Web/Phantom.Web/Shared/InstanceLog.razor index 65d2ff6..69ba35b 100644 --- a/Web/Phantom.Web/Shared/InstanceLog.razor +++ b/Web/Phantom.Web/Shared/InstanceLog.razor @@ -1,11 +1,11 @@ -@inherits PhantomComponent -@using Phantom.Utils.Collections +@using Phantom.Utils.Collections @using Phantom.Utils.Events @using System.Diagnostics -@using Phantom.Controller.Services.Instances -@implements IDisposable +@using Phantom.Web.Services.Instances +@using Phantom.Common.Data.Web.Users +@inherits PhantomComponent @inject IJSRuntime Js; -@inject InstanceLogManager InstanceLogManager +@inject InstanceLogManager InstanceLogManager; <div id="log" class="font-monospace mb-3"> @foreach (var line in instanceLogs.EnumerateLast(uint.MaxValue)) { @@ -16,7 +16,7 @@ @code { [Parameter, EditorRequired] - public Guid InstanceGuid { get; set; } + public Guid InstanceGuid { get; init; } private IJSObjectReference? PageJs { get; set; } @@ -57,7 +57,7 @@ private async Task RecheckPermissions() { recheckPermissionsStopwatch.Restart(); - + if (!await CheckPermission(Permission.ViewInstanceLogs)) { await Task.Yield(); Dispose(); @@ -65,7 +65,7 @@ } } - public void Dispose() { + protected override void OnDisposed() { instanceLogsSubs.Unsubscribe(this); } diff --git a/Web/Phantom.Web/Shared/InstanceStopDialog.razor b/Web/Phantom.Web/Shared/InstanceStopDialog.razor index 49152a1..336f6f7 100644 --- a/Web/Phantom.Web/Shared/InstanceStopDialog.razor +++ b/Web/Phantom.Web/Shared/InstanceStopDialog.razor @@ -1,12 +1,11 @@ -@using Phantom.Controller.Services.Instances -@using Phantom.Controller.Services.Audit +@using Phantom.Web.Services.Instances @using System.ComponentModel.DataAnnotations +@using Phantom.Common.Data.Web.Users @using Phantom.Common.Data.Minecraft @using Phantom.Common.Data.Replies @inherits PhantomComponent @inject IJSRuntime Js; @inject InstanceManager InstanceManager; -@inject AuditLog AuditLog <Form Model="form" OnSubmit="StopInstance"> <Modal Id="@ModalId" TitleText="Stop Instance"> @@ -33,13 +32,13 @@ @code { [Parameter, EditorRequired] - public Guid InstanceGuid { get; set; } + public Guid InstanceGuid { get; init; } [Parameter, EditorRequired] - public string ModalId { get; set; } = string.Empty; + public string ModalId { get; init; } = string.Empty; [Parameter] - public bool Disabled { get; set; } + public bool Disabled { get; init; } private readonly StopInstanceFormModel form = new (); @@ -51,14 +50,14 @@ private async Task StopInstance(EditContext context) { await form.SubmitModel.StartSubmitting(); - if (!await CheckPermission(Permission.ControlInstances)) { + var loggedInUserGuid = await GetUserGuid(); + if (loggedInUserGuid == null || !await CheckPermission(Permission.ControlInstances)) { form.SubmitModel.StopSubmitting("You do not have permission to stop instances."); return; } - - var result = await InstanceManager.StopInstance(InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds)); + + var result = await InstanceManager.StopInstance(loggedInUserGuid.Value, InstanceGuid, new MinecraftStopStrategy(form.StopInSeconds), CancellationToken); if (result.Is(StopInstanceResult.StopInitiated)) { - await AuditLog.AddInstanceStoppedEvent(InstanceGuid, form.StopInSeconds); await Js.InvokeVoidAsync("closeModal", ModalId); form.SubmitModel.StopSubmitting(); } diff --git a/Web/Phantom.Web/Shared/UserAddDialog.razor b/Web/Phantom.Web/Shared/UserAddDialog.razor index b092253..dcc7bc5 100644 --- a/Web/Phantom.Web/Shared/UserAddDialog.razor +++ b/Web/Phantom.Web/Shared/UserAddDialog.razor @@ -1,17 +1,15 @@ -@using Phantom.Controller.Services.Users -@using Phantom.Utils.Tasks -@using Phantom.Controller.Database.Entities -@using Phantom.Controller.Services.Audit +@using Phantom.Common.Data.Web.Users +@using Phantom.Common.Data.Web.Users.CreateUserResults +@using Phantom.Web.Services.Users @using System.ComponentModel.DataAnnotations @inherits PhantomComponent @inject IJSRuntime Js; -@inject UserManager UserManager -@inject AuditLog AuditLog +@inject UserManager UserManager; <Form Model="form" OnSubmit="AddUser"> <Modal Id="@ModalId" TitleText="Add User"> <Body> - + <div class="row"> <div class="mb-3"> <FormTextInput Id="account-username" Label="Username" @bind-Value="form.Username" autocomplete="off" /> @@ -23,7 +21,7 @@ <FormTextInput Id="account-password" Label="Password" Type="FormTextInputType.Password" autocomplete="new-password" @bind-Value="form.Password" /> </div> </div> - + </Body> <Footer> <FormSubmitError /> @@ -37,9 +35,9 @@ [Parameter, EditorRequired] public string ModalId { get; set; } = string.Empty; - + [Parameter] - public EventCallback<UserEntity> UserAdded { get; set; } + public EventCallback<UserInfo> UserAdded { get; set; } private readonly AddUserFormModel form = new(); @@ -54,22 +52,26 @@ private async Task AddUser(EditContext context) { await form.SubmitModel.StartSubmitting(); - if (!await CheckPermission(Permission.EditUsers)) { + var loggedInUserGuid = await GetUserGuid(); + if (loggedInUserGuid == null || !await CheckPermission(Permission.EditUsers)) { form.SubmitModel.StopSubmitting("You do not have permission to add users."); return; } - switch (await UserManager.CreateUser(form.Username, form.Password)) { - case Result<UserEntity, AddUserError>.Ok ok: - await AuditLog.AddUserCreatedEvent(ok.Value); - await UserAdded.InvokeAsync(ok.Value); + switch (await UserManager.Create(loggedInUserGuid.Value, form.Username, form.Password, CancellationToken)) { + case Success success: + await UserAdded.InvokeAsync(success.User); await Js.InvokeVoidAsync("closeModal", ModalId); form.SubmitModel.StopSubmitting(); break; - - case Result<UserEntity, AddUserError>.Fail fail: + + case CreationFailed fail: form.SubmitModel.StopSubmitting(fail.Error.ToSentences("\n")); break; + + default: + form.SubmitModel.StopSubmitting("Unknown error."); + break; } } diff --git a/Web/Phantom.Web/Shared/UserDeleteDialog.razor b/Web/Phantom.Web/Shared/UserDeleteDialog.razor index 33979c4..78440e9 100644 --- a/Web/Phantom.Web/Shared/UserDeleteDialog.razor +++ b/Web/Phantom.Web/Shared/UserDeleteDialog.razor @@ -1,9 +1,7 @@ -@using Phantom.Controller.Database.Entities -@using Phantom.Controller.Services.Audit -@using Phantom.Controller.Services.Users +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Users @inherits UserEditDialogBase @inject UserManager UserManager -@inject AuditLog AuditLog <Modal Id="@ModalId" TitleText="Delete User"> <Body> @@ -19,17 +17,13 @@ @code { - protected override async Task DoEdit(UserEntity user) { - switch (await UserManager.DeleteByGuid(user.UserGuid)) { + protected override async Task DoEdit(Guid loggedInUserGuid, UserInfo user) { + switch (await UserManager.DeleteByGuid(loggedInUserGuid, user.Guid, CancellationToken)) { case DeleteUserResult.Deleted: - await AuditLog.AddUserDeletedEvent(user); - await OnEditSuccess(); - break; - case DeleteUserResult.NotFound: await OnEditSuccess(); break; - + case DeleteUserResult.Failed: OnEditFailure("Could not delete user."); break; diff --git a/Web/Phantom.Web/Shared/UserEditDialogBase.cs b/Web/Phantom.Web/Shared/UserEditDialogBase.cs index 99320f5..481fb7d 100644 --- a/Web/Phantom.Web/Shared/UserEditDialogBase.cs +++ b/Web/Phantom.Web/Shared/UserEditDialogBase.cs @@ -1,9 +1,8 @@ using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; -using Phantom.Controller.Database.Entities; +using Phantom.Common.Data.Web.Users; using Phantom.Web.Base; using Phantom.Web.Components.Forms; -using Phantom.Web.Identity.Data; namespace Phantom.Web.Shared; @@ -15,14 +14,14 @@ public abstract class UserEditDialogBase : PhantomComponent { public string ModalId { get; set; } = string.Empty; [Parameter] - public EventCallback<UserEntity> UserModified { get; set; } + public EventCallback<UserInfo> UserModified { get; set; } protected readonly FormButtonSubmit.SubmitModel SubmitModel = new(); - private UserEntity? EditedUser { get; set; } = null; + private UserInfo? EditedUser { get; set; } = null; protected string EditedUserName { get; private set; } = string.Empty; - internal async Task Show(UserEntity user) { + internal async Task Show(UserInfo user) { EditedUser = user; EditedUserName = user.Name; await BeforeShown(user); @@ -31,7 +30,7 @@ public abstract class UserEditDialogBase : PhantomComponent { await Js.InvokeVoidAsync("showModal", ModalId); } - protected virtual Task BeforeShown(UserEntity user) { + protected virtual Task BeforeShown(UserInfo user) { return Task.CompletedTask; } @@ -42,18 +41,19 @@ public abstract class UserEditDialogBase : PhantomComponent { protected async Task Submit() { await SubmitModel.StartSubmitting(); - if (!await CheckPermission(Permission.EditUsers)) { + var loggedInUserGuid = await GetUserGuid(); + if (loggedInUserGuid == null || !await CheckPermission(Permission.EditUsers)) { SubmitModel.StopSubmitting("You do not have permission to edit users."); } else if (EditedUser == null) { SubmitModel.StopSubmitting("Invalid user."); } else { - await DoEdit(EditedUser); + await DoEdit(loggedInUserGuid.Value, EditedUser); } } - protected abstract Task DoEdit(UserEntity user); + protected abstract Task DoEdit(Guid loggedInUserGuid, UserInfo user); protected async Task OnEditSuccess() { await UserModified.InvokeAsync(EditedUser); diff --git a/Web/Phantom.Web/Shared/UserRolesDialog.razor b/Web/Phantom.Web/Shared/UserRolesDialog.razor index a8e1dfa..046df3a 100644 --- a/Web/Phantom.Web/Shared/UserRolesDialog.razor +++ b/Web/Phantom.Web/Shared/UserRolesDialog.razor @@ -1,11 +1,9 @@ -@using Phantom.Controller.Database.Entities -@using Phantom.Controller.Services.Audit -@using Phantom.Controller.Services.Users +@using System.Collections.Immutable +@using Phantom.Common.Data.Web.Users +@using Phantom.Web.Services.Users @inherits UserEditDialogBase -@inject UserManager UserManager @inject RoleManager RoleManager @inject UserRoleManager UserRoleManager -@inject AuditLog AuditLog <Modal Id="@ModalId" TitleText="Manage User Roles"> <Body> @@ -27,53 +25,73 @@ @code { + private ImmutableDictionary<Guid, RoleInfo> allRolesByGuid = ImmutableDictionary<Guid, RoleInfo>.Empty; private List<RoleItem> items = new(); - protected override async Task BeforeShown(UserEntity user) { - var userRoles = await UserRoleManager.GetUserRoleGuids(user); - var allRoles = await RoleManager.GetAll(); - this.items = allRoles.Select(role => new RoleItem(role, userRoles.Contains(role.RoleGuid))).ToList(); + protected override async Task BeforeShown(UserInfo user) { + var allRoles = await RoleManager.GetAll(CancellationToken); + this.allRolesByGuid = allRoles.ToImmutableDictionary(static role => role.Guid, static role => role); + + var currentRoleGuids = await UserRoleManager.GetUserRoles(user.Guid, CancellationToken); + this.items = allRoles.Select(role => new RoleItem(role, currentRoleGuids.Contains(role.Guid))).ToList(); } - protected override async Task DoEdit(UserEntity user) { - var userRoles = await UserRoleManager.GetUserRoleGuids(user); - var addedToRoles = new List<string>(); - var removedFromRoles = new List<string>(); - var errors = new List<string>(); + protected override async Task DoEdit(Guid loggedInUserGuid, UserInfo user) { + var currentRoleGuids = await UserRoleManager.GetUserRoles(user.Guid, CancellationToken); + var addToRoleGuids = ImmutableHashSet.CreateBuilder<Guid>(); + var removeFromRoleGuids = ImmutableHashSet.CreateBuilder<Guid>(); foreach (var item in items) { + var roleGuid = item.Role.Guid; var shouldHaveRole = item.Checked; - if (shouldHaveRole == userRoles.Contains(item.Role.RoleGuid)) { + if (shouldHaveRole == currentRoleGuids.Contains(roleGuid)) { continue; } - bool success = shouldHaveRole ? await UserRoleManager.Add(user, item.Role) : await UserRoleManager.Remove(user, item.Role); - if (success) { - var modifiedList = shouldHaveRole ? addedToRoles : removedFromRoles; - modifiedList.Add(item.Role.Name); - } - else if (shouldHaveRole) { - errors.Add("Could not add role " + item.Role.Name + " to user."); + if (shouldHaveRole) { + addToRoleGuids.Add(roleGuid); } else { - errors.Add("Could not remove role " + item.Role.Name + " from user."); + removeFromRoleGuids.Add(roleGuid); } } + + await DoChangeUserRoles(user, loggedInUserGuid, addToRoleGuids.ToImmutable(), removeFromRoleGuids.ToImmutable()); + } - if (errors.Count == 0) { - await AuditLog.AddUserRolesChangedEvent(user, addedToRoles, removedFromRoles); + private async Task DoChangeUserRoles(UserInfo user, Guid loggedInUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) { + var result = await UserRoleManager.ChangeUserRoles(loggedInUserGuid, user.Guid, addToRoleGuids, removeFromRoleGuids, CancellationToken); + + var failedToAdd = addToRoleGuids.Except(result.AddedToRoleGuids); + var failedToRemove = removeFromRoleGuids.Except(result.RemovedFromRoleGuids); + + if (failedToAdd.IsEmpty && failedToRemove.IsEmpty) { await OnEditSuccess(); + return; } - else { - OnEditFailure(string.Join("\n", errors)); + + var errors = new List<string>(); + + foreach (var roleGuid in failedToAdd) { + errors.Add("Could not add role: " + GetRoleName(roleGuid)); } + + foreach (var roleGuid in failedToRemove) { + errors.Add("Could not remove role: " + GetRoleName(roleGuid)); + } + + OnEditFailure(string.Join("\n", errors)); + } + + private string GetRoleName(Guid roleGuid) { + return allRolesByGuid.TryGetValue(roleGuid, out var role) ? role.Name : "?"; } private sealed class RoleItem { - public RoleEntity Role { get; } + public RoleInfo Role { get; } public bool Checked { get; set; } - public RoleItem(RoleEntity role, bool @checked) { + public RoleItem(RoleInfo role, bool @checked) { this.Role = role; this.Checked = @checked; } diff --git a/Web/Phantom.Web/Utils/Messages.cs b/Web/Phantom.Web/Utils/Messages.cs index 86d0279..46a23d4 100644 --- a/Web/Phantom.Web/Utils/Messages.cs +++ b/Web/Phantom.Web/Utils/Messages.cs @@ -1,36 +1,43 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Replies; -using Phantom.Controller.Minecraft; -using Phantom.Controller.Services.Users; +using Phantom.Common.Data.Web.Minecraft; +using Phantom.Common.Data.Web.Users; namespace Phantom.Web.Utils; static class Messages { public static string ToSentences(this AddUserError error, string delimiter) { return error switch { - AddUserError.NameIsEmpty => "Name cannot be empty.", - AddUserError.NameIsTooLong e => "Name cannot be longer than " + e.MaximumLength + " character(s).", - AddUserError.NameAlreadyExists => "Name is already occupied.", - AddUserError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), - _ => "Unknown error." + Common.Data.Web.Users.AddUserErrors.NameIsInvalid e => e.Violation.ToSentence(), + Common.Data.Web.Users.AddUserErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), + Common.Data.Web.Users.AddUserErrors.NameAlreadyExists => "Username is already occupied.", + _ => "Unknown error." }; } public static string ToSentences(this SetUserPasswordError error, string delimiter) { return error switch { - SetUserPasswordError.UserNotFound => "User not found.", - SetUserPasswordError.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), - _ => "Unknown error." + Common.Data.Web.Users.SetUserPasswordErrors.UserNotFound => "User not found.", + Common.Data.Web.Users.SetUserPasswordErrors.PasswordIsInvalid e => string.Join(delimiter, e.Violations.Select(static v => v.ToSentence())), + _ => "Unknown error." + }; + } + + public static string ToSentence(this UsernameRequirementViolation violation) { + return violation switch { + Common.Data.Web.Users.UsernameRequirementViolations.IsEmpty => "Username must not be empty.", + Common.Data.Web.Users.UsernameRequirementViolations.TooLong v => "Username must not be longer than " + v.MaxLength + " character(s).", + _ => "Unknown error." }; } public static string ToSentence(this PasswordRequirementViolation violation) { return violation switch { - PasswordRequirementViolation.TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.", - PasswordRequirementViolation.LowercaseLetterRequired => "Password must contain a lowercase letter.", - PasswordRequirementViolation.UppercaseLetterRequired => "Password must contain an uppercase letter.", - PasswordRequirementViolation.DigitRequired => "Password must contain a digit.", - _ => "Unknown error." + Common.Data.Web.Users.PasswordRequirementViolations.TooShort v => "Password must be at least " + v.MinimumLength + " character(s) long.", + Common.Data.Web.Users.PasswordRequirementViolations.MustContainLowercaseLetter => "Password must contain a lowercase letter.", + Common.Data.Web.Users.PasswordRequirementViolations.MustContainUppercaseLetter => "Password must contain an uppercase letter.", + Common.Data.Web.Users.PasswordRequirementViolations.MustContainDigit => "Password must contain a digit.", + _ => "Unknown error." }; } diff --git a/Web/Phantom.Web/Variables.cs b/Web/Phantom.Web/Variables.cs new file mode 100644 index 0000000..1e1366d --- /dev/null +++ b/Web/Phantom.Web/Variables.cs @@ -0,0 +1,37 @@ +using Phantom.Common.Logging; +using Phantom.Utils.Runtime; + +namespace Phantom.Web; + +sealed record Variables( + string ControllerHost, + ushort ControllerPort, + string? WebKeyToken, + string? WebKeyFilePath, + string WebServerHost, + ushort WebServerPort, + string WebBasePath +) { + private static Variables LoadOrThrow() { + var (webKeyToken, webKeyFilePath) = EnvironmentVariables.GetEitherString("WEB_KEY", "WEB_KEY_FILE").Require; + + return new Variables( + EnvironmentVariables.GetString("CONTROLLER_HOST").Require, + EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9402), + webKeyToken, + webKeyFilePath, + 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; + } + } +} diff --git a/Web/Phantom.Web/WebKey.cs b/Web/Phantom.Web/WebKey.cs new file mode 100644 index 0000000..9fc5cb7 --- /dev/null +++ b/Web/Phantom.Web/WebKey.cs @@ -0,0 +1,60 @@ +using NetMQ; +using Phantom.Common.Data; +using Phantom.Common.Logging; +using Phantom.Utils.Cryptography; +using Phantom.Utils.IO; +using ILogger = Serilog.ILogger; + +namespace Phantom.Web; + +static class WebKey { + private static ILogger Logger { get; } = PhantomLogger.Create(nameof(WebKey)); + + public static Task<(NetMQCertificate, AuthToken)?> Load(string? webKeyToken, string? webKeyFilePath) { + if (webKeyFilePath != null) { + return LoadFromFile(webKeyFilePath); + } + else if (webKeyToken != null) { + return Task.FromResult(LoadFromToken(webKeyToken)); + } + else { + throw new InvalidOperationException(); + } + } + + private static async Task<(NetMQCertificate, AuthToken)?> LoadFromFile(string webKeyFilePath) { + if (!File.Exists(webKeyFilePath)) { + Logger.Fatal("Missing web key file: {WebKeyFilePath}", webKeyFilePath); + return null; + } + + try { + Files.RequireMaximumFileSize(webKeyFilePath, 64); + return LoadFromBytes(await File.ReadAllBytesAsync(webKeyFilePath)); + } catch (IOException e) { + Logger.Fatal("Error loading web key from file: {WebKeyFilePath}", webKeyFilePath); + Logger.Fatal(e.Message); + return null; + } catch (Exception) { + Logger.Fatal("File does not contain a valid web key: {WebKeyFilePath}", webKeyFilePath); + return null; + } + } + + private static (NetMQCertificate, AuthToken)? LoadFromToken(string webKey) { + try { + return LoadFromBytes(TokenGenerator.DecodeBytes(webKey)); + } catch (Exception) { + Logger.Fatal("Invalid web key: {WebKey}", webKey); + return null; + } + } + + private static (NetMQCertificate, AuthToken)? LoadFromBytes(byte[] webKey) { + var (publicKey, webToken) = ConnectionCommonKey.FromBytes(webKey); + var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey); + + Logger.Information("Loaded web key."); + return (controllerCertificate, webToken); + } +} diff --git a/Web/Phantom.Web/Launcher.cs b/Web/Phantom.Web/WebLauncher.cs similarity index 80% rename from Web/Phantom.Web/Launcher.cs rename to Web/Phantom.Web/WebLauncher.cs index 58beb4c..42735b5 100644 --- a/Web/Phantom.Web/Launcher.cs +++ b/Web/Phantom.Web/WebLauncher.cs @@ -1,16 +1,16 @@ using Microsoft.AspNetCore.DataProtection; -using Phantom.Controller.Services; +using Phantom.Common.Messages.Web; +using Phantom.Utils.Rpc; using Phantom.Utils.Tasks; using Phantom.Web.Base; -using Phantom.Web.Identity; -using Phantom.Web.Identity.Interfaces; +using Phantom.Web.Services; using Serilog; namespace Phantom.Web; -public static class Launcher { - public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) { - var assembly = typeof(Launcher).Assembly; +static class WebLauncher { + public static WebApplication CreateApplication(Configuration config, TaskManager taskManager, ServiceConfiguration serviceConfiguration, RpcConnectionToServer<IMessageToControllerListener> controllerConnection) { + var assembly = typeof(WebLauncher).Assembly; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ApplicationName = assembly.GetName().Name, ContentRootPath = Path.GetDirectoryName(assembly.Location) @@ -25,16 +25,15 @@ public static class Launcher { builder.WebHost.UseStaticWebAssets(); } - builder.Services.AddSingleton(serviceConfiguration); builder.Services.AddSingleton(taskManager); + builder.Services.AddSingleton(serviceConfiguration); + builder.Services.AddSingleton(controllerConnection); + builder.Services.AddPhantomServices(); builder.Services.AddSingleton<IHostLifetime>(new NullLifetime()); builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath)); - builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath)); - - builder.Services.AddPhantomIdentity(config.CancellationToken); - builder.Services.AddScoped<ILoginEvents, LoginEvents>(); + builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.DataProtectionKeyFolderPath)); builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout"); builder.Services.AddServerSideBlazor(); @@ -54,7 +53,7 @@ public static class Launcher { application.UseStaticFiles(); application.UseRouting(); - application.UsePhantomIdentity(); + application.UsePhantomServices(); application.MapControllers(); application.MapBlazorHub(); diff --git a/Web/Phantom.Web/_Imports.razor b/Web/Phantom.Web/_Imports.razor index 45bb703..338c09a 100644 --- a/Web/Phantom.Web/_Imports.razor +++ b/Web/Phantom.Web/_Imports.razor @@ -12,9 +12,6 @@ @using Phantom.Web.Components.Forms @using Phantom.Web.Components.Graphics @using Phantom.Web.Components.Tables -@using Phantom.Web.Identity -@using Phantom.Web.Identity.Authorization -@using Phantom.Web.Identity.Data @using Phantom.Web.Layout @using Phantom.Web.Shared @using Phantom.Web.Utils