diff --git a/.dockerignore b/.dockerignore index c3189160e5..341bd88cf1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,5 +5,7 @@ docker-compose.yml .vs .vscode legacy +packages +build */bin */obj diff --git a/.gitignore b/.gitignore index a0fa5f96e2..9b60069c91 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ artifacts/ *DS_Store *.sln.ide /TestResults +/src/Tgstation.Server.Host/appsettings.Development.json diff --git a/build/Dockerfile b/build/Dockerfile index 5332fa7e91..2b1cb8c47d 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM microsoft/dotnet:2.1-sdk AS build +FROM microsoft/dotnet:2.0-sdk AS build WORKDIR /src COPY tgstation-server.sln ./ @@ -36,8 +36,14 @@ RUN dotnet test -c Release FROM build AS publish RUN dotnet publish -c Release -o /app -FROM microsoft/dotnet:2.1-runtime +FROM microsoft/dotnet:2.0-runtime EXPOSE 80 + WORKDIR /app + COPY --from=publish /app . + +RUN mkdir /config_data && mv appsettings.Docker.json /config_data/appsettings.Production.json +VOLUME ["/config_data"] + ENTRYPOINT ["dotnet", "Tgstation.Server.Host.Console.dll"] diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs new file mode 100644 index 0000000000..5db3493ae5 --- /dev/null +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http.Headers; +using System.Reflection; +using Microsoft.AspNetCore.Http.Headers; +using Microsoft.Net.Http.Headers; +using Microsoft.Extensions.Primitives; + +namespace Tgstation.Server.Api +{ + /// + /// Represents the header that must be present for every server request + /// + public sealed class ApiHeaders + { + /// + /// TODO: Remove this when https://github.com/dotnet/corefx/pull/26701 makes it into the sdk + /// + public const string ApplicationJson = "application/json"; + + /// + /// The header key + /// + const string usernameHeader = "Username"; + + /// + /// The header key + /// + const string instanceIdHeader = "InstanceId"; + + /// + /// The JWT authentication header scheme + /// + const string jwtAuthenticationScheme = "Bearer"; + + /// + /// The password authentication header scheme + /// + const string passwordAuthenticationScheme = "Password"; + + /// + /// The current + /// + static readonly AssemblyName assemblyName = Assembly.GetExecutingAssembly().GetName(); + + /// + /// The being accessed + /// + public long? InstanceId { get; set; } + + /// + /// The client's user agent + /// + public ProductHeaderValue UserAgent { get; } + + /// + /// The client's API version + /// + public Version ApiVersion { get; } + + /// + /// The client's JWT + /// + public string Token { get; } + + /// + /// The client's username + /// + public string Username { get; } + + /// + /// The client's password + /// + public string Password { get; } + + /// + /// If the header uses password or JWT authentication + /// + public bool IsTokenAuthentication => Token != null; + + /// + /// Construct for JWT authentication + /// + /// The value of + /// The value of + public ApiHeaders(ProductHeaderValue userAgent, string token) : this(userAgent, token, null, null) + { + if (userAgent == null) + throw new ArgumentNullException(nameof(userAgent)); + if (token == null) + throw new ArgumentNullException(nameof(token)); + } + + /// + /// Construct for password authentication + /// + /// The value of + /// The value of + /// The value of + public ApiHeaders(ProductHeaderValue userAgent, string username, string password) : this(userAgent, null, username, password) + { + if (userAgent == null) + throw new ArgumentNullException(nameof(userAgent)); + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + } + + /// + /// Construct and validates from a set of + /// + /// The containing the + public ApiHeaders(RequestHeaders requestHeaders) + { + var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJson); + if (!requestHeaders.Accept.Any(x => x.MediaType == jsonAccept.MediaType)) + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Client does not accept {0}!", ApplicationJson)); + + if (!requestHeaders.Headers.TryGetValue(HeaderNames.UserAgent, out StringValues userAgentValues) || !ProductInfoHeaderValue.TryParse(userAgentValues.FirstOrDefault(), out ProductInfoHeaderValue clientUserAgent)) + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Missing {0} headers!", HeaderNames.UserAgent)); + + //assure the client user agent has a name and version + if (String.IsNullOrWhiteSpace(clientUserAgent.Product.Name) || !Version.TryParse(clientUserAgent.Product.Version, out Version clientVersion)) + throw new InvalidOperationException("Malformed client user agent!"); + + var apiUserAgentHeader = userAgentValues[1]; + //make sure the api header matches ours + if (!ProductInfoHeaderValue.TryParse(apiUserAgentHeader, out ProductInfoHeaderValue apiUserAgent) || apiUserAgent.Product.Name != assemblyName.Name) + throw new InvalidOperationException("Missing API user agent!"); + + if (!Version.TryParse(apiUserAgent.Product.Version, out Version ApiVersion)) + throw new InvalidOperationException("Malformed API version!"); + + //check api version compatibility + var ourVersion = assemblyName.Version; + if (ourVersion.Major != ApiVersion.Major || ourVersion.Minor != ApiVersion.Minor || ourVersion.Build > ApiVersion.Build) + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Given API version is incompatible with version {0}!", ourVersion)); + + if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization)) + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Missing {0} header!", HeaderNames.Authorization)); + var auth = authorization.First(); + var splits = new List(auth.Split(' ')); + var scheme = splits.First(); + if (String.IsNullOrWhiteSpace(scheme)) + throw new InvalidOperationException("Missing authentication scheme!"); + + splits.RemoveAt(0); + var parameter = String.Concat(splits); + if (String.IsNullOrEmpty(parameter)) + throw new InvalidOperationException("Missing authentication parameter!"); + + if(requestHeaders.Headers.TryGetValue(instanceIdHeader, out StringValues instanceIdValues)) + { + var instanceIdString = instanceIdValues.FirstOrDefault(); + if (instanceIdString != default && Int64.TryParse(instanceIdString, out long instanceId)) + InstanceId = instanceId; + } + + switch (scheme) + { + case jwtAuthenticationScheme: + Token = parameter; + break; + case passwordAuthenticationScheme: + Password = parameter; + var fail = !requestHeaders.Headers.TryGetValue(usernameHeader, out StringValues values); + if (!fail) + { + Username = values.FirstOrDefault(); + fail = String.IsNullOrWhiteSpace(Username); + } + if (fail) + throw new InvalidOperationException("Missing Username header!"); + break; + default: + throw new InvalidOperationException("Invalid authentication scheme!"); + } + } + + /// + /// Construct + /// + /// The value of + /// The value of + /// The value of + /// The value of + ApiHeaders(ProductHeaderValue userAgent, string token, string username, string password) + { + UserAgent = userAgent; + Token = token; + Username = username; + Password = password; + } + + /// + /// Set using the . This initially clears + /// + /// The to set + public void SetRequestHeaders(HttpRequestHeaders headers) + { + if (headers == null) + throw new ArgumentNullException(nameof(headers)); + + headers.Clear(); + headers.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJson)); + if (IsTokenAuthentication) + headers.Authorization = new AuthenticationHeaderValue(jwtAuthenticationScheme, Token); + else + { + headers.Authorization = new AuthenticationHeaderValue(passwordAuthenticationScheme, Password); + headers.Add(usernameHeader, Username); + } + headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent)); + headers.UserAgent.Add(new ProductInfoHeaderValue(new ProductHeaderValue(assemblyName.Name, assemblyName.Version.ToString()))); + if(InstanceId.HasValue) + headers.Add(instanceIdHeader, InstanceId.ToString()); + } + } +} diff --git a/src/Tgstation.Server.Api/ModelAttribute.cs b/src/Tgstation.Server.Api/ModelAttribute.cs index 6145721634..df50284cf3 100644 --- a/src/Tgstation.Server.Api/ModelAttribute.cs +++ b/src/Tgstation.Server.Api/ModelAttribute.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api /// /// Indicates the rights for a model /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] public sealed class ModelAttribute : Attribute { /// diff --git a/src/Tgstation.Server.Api/Models/Administration.cs b/src/Tgstation.Server.Api/Models/Administration.cs index 7fd8bdfc84..7f04e2d428 100644 --- a/src/Tgstation.Server.Api/Models/Administration.cs +++ b/src/Tgstation.Server.Api/Models/Administration.cs @@ -4,36 +4,15 @@ using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models { - /// - /// Metadata about an installation - /// - [Model(RightsType.Administration)] - public sealed class Administration + /// + public sealed class Administration : Internal.ServerSettings { - /// - /// Use the specified Windows/UNIX authentication to authorize users. Setting this to enables full administrative anonymous access - /// - [Permissions(ReadRight = AdministrationRights.ChangeAuthenticationGroup, WriteRight = AdministrationRights.ChangeAuthenticationGroup)] - public string SystemAuthenticationGroup { get; set; } - - /// - /// Automatically send unhandled exception data to a public collection service. This will be limited to system information, path data, and game code compilation information. - /// - [Permissions(ReadRight = AdministrationRights.ChangeTelemetry, WriteRight = AdministrationRights.ChangeTelemetry)] - public bool EnableTelemetry { get; set; } - /// /// If the instances will not be stopped when the server exits. Resets to when the server restarts /// [Permissions(ReadRight = AdministrationRights.SoftStop, WriteRight = AdministrationRights.SoftStop)] public bool SoftStop { get; set; } - - /// - /// The git repository to recieve updates to Tgstation.Server.Host from, must include credentials if necessary. If set to upstream pulls will be disabled entirely - /// - [Permissions(ReadRight = AdministrationRights.SetUpstreamRepository, WriteRight = AdministrationRights.SetUpstreamRepository)] - public string UpstreamRepository { get; set; } - + /// /// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If is higher than 's the update cannot be applied due to API changes /// @@ -47,7 +26,7 @@ namespace Tgstation.Server.Api.Models public Version CurrentVersion { get; set; } /// - /// Users in the + /// Users in the /// [Permissions(DenyWrite = true)] public IReadOnlyList Users { get; set; } diff --git a/src/Tgstation.Server.Api/Models/Chat.cs b/src/Tgstation.Server.Api/Models/Chat.cs deleted file mode 100644 index 543eae1230..0000000000 --- a/src/Tgstation.Server.Api/Models/Chat.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Api.Models -{ - /// - /// Manage the server chat bots - /// - [Model(RightsType.Chat, RequiresInstance = true)] - public sealed class Chat - { - /// - /// If the IRC client is enabled - /// - [Permissions(WriteRight = ChatRights.SetIrcEnabled)] - public bool IrcEnabled { get; set; } - - /// - /// The IRC server name - /// - [Permissions(ReadRight = ChatRights.SetIrcSettings, WriteRight = ChatRights.SetIrcSettings)] - public string IrcHost { get; set; } - - /// - /// The IRC server port - /// - [Permissions(ReadRight = ChatRights.SetIrcSettings, WriteRight = ChatRights.SetIrcSettings)] - public ushort IrcPort { get; set; } - - /// - /// The IRC server NickServ password - /// - [Permissions(ReadRight = ChatRights.SetIrcSettings, WriteRight = ChatRights.SetIrcSettings)] - public string IrcNickServPassword { get; set; } - - /// - /// Channels the IRC client should join/listen/announce in and allow admin commands - /// - [Permissions(WriteRight = ChatRights.SetIrcChannels)] - public List IrcAdminChannels { get; set; } - - /// - /// Channels the IRC client should join/listen/announce in - /// - [Permissions(WriteRight = ChatRights.SetIrcChannels)] - public List IrcGeneralChannels { get; set; } - - /// - /// If the Discord bot is enabled - /// - [Permissions(WriteRight = ChatRights.SetDiscordEnabled)] - public bool DiscordEnabled { get; set; } - - /// - /// The Discord bot token - /// - [Permissions(ReadRight = ChatRights.SetDiscordSettings, WriteRight = ChatRights.SetDiscordSettings)] - public string DiscordBotToken { get; set; } - - /// - /// Channels the Discord bot should listen/announce in and allow admin commands - /// - [Permissions(WriteRight = ChatRights.SetDiscordChannels)] - public List DiscordAdminChannels { get; set; } - /// - /// Channels the Discord bot should listen/announce in - /// - [Permissions(WriteRight = ChatRights.SetDiscordChannels)] - public List DiscordGeneralChannels { get; set; } - } -} diff --git a/src/Tgstation.Server.Api/Models/ChatChannel.cs b/src/Tgstation.Server.Api/Models/ChatChannel.cs new file mode 100644 index 0000000000..a927090029 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/ChatChannel.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Api.Models +{ + /// + /// Indicates a chat channel + /// + public class ChatChannel + { + /// + /// The IRC channel name + /// + public string IrcChannel { get; set; } + + /// + /// The Discord channel ID + /// + public long DiscordChannelId { get; set; } + + /// + /// If the is an admin channel + /// + public bool IsAdminChannel { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/ChatSettings.cs b/src/Tgstation.Server.Api/Models/ChatSettings.cs new file mode 100644 index 0000000000..a4cb033170 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/ChatSettings.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models +{ + /// + public sealed class ChatSettings : Internal.ChatSettings + { + /// + /// Channels the Discord bot should listen/announce in + /// + [Permissions(WriteRight = ChatSettingsRights.SetChannels)] + public List Channels { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/CompileJob.cs b/src/Tgstation.Server.Api/Models/CompileJob.cs new file mode 100644 index 0000000000..2cc0f74988 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/CompileJob.cs @@ -0,0 +1,16 @@ +namespace Tgstation.Server.Api.Models +{ + /// + public sealed class CompileJob : Internal.CompileJob + { + /// + /// The that triggered the job + /// + public User TriggeredBy { get; set; } + + /// + /// Git revision the compiler ran on. Not modifiable + /// + public RevisionInformation RevisionInformation { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Configuration.cs b/src/Tgstation.Server.Api/Models/Configuration.cs index 4b750c9e6a..16b57be176 100644 --- a/src/Tgstation.Server.Api/Models/Configuration.cs +++ b/src/Tgstation.Server.Api/Models/Configuration.cs @@ -30,9 +30,7 @@ namespace Tgstation.Server.Api.Models /// /// The content of the file. Will be if is or during listing operations /// -#pragma warning disable CA1819 // Properties should not return arrays public byte[] Content { get; set; } -#pragma warning restore CA1819 // Properties should not return arrays /// /// The MD5 hash of the file when last read by the user. Will be if is . If this doesn't match during update actions, the write will be denied with error code 409 diff --git a/src/Tgstation.Server.Api/Models/DreamDaemon.cs b/src/Tgstation.Server.Api/Models/DreamDaemon.cs index 652256ef5b..49571a3fa3 100644 --- a/src/Tgstation.Server.Api/Models/DreamDaemon.cs +++ b/src/Tgstation.Server.Api/Models/DreamDaemon.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models @@ -7,38 +6,13 @@ namespace Tgstation.Server.Api.Models /// /// Represents an instance of BYOND's DreamDaemon game server. Create action starts the server. Delete action shuts down the server /// - [Model(RightsType.DreamDaemon, CanCrud = true, RequiresInstance = true)] - public sealed class DreamDaemon + public sealed class DreamDaemon : DreamDaemonSettings { - /// - /// The pull requests merged on the live game version - /// - [Permissions(DenyWrite = true, ReadRight = DreamDaemonRights.ReadRevision)] - public IReadOnlyDictionary PullRequests { get; set; } - - /// - /// The git sha the live game version was compiled at - /// - [Permissions(DenyWrite = true, ReadRight = DreamDaemonRights.ReadRevision)] - public string Sha { get; set; } - - /// - /// The git sha of the origin branch the live game version was compiled at - /// - [Permissions(DenyWrite = true, ReadRight = DreamDaemonRights.ReadRevision)] - public string OriginSha { get; set; } - /// /// When the live revision was compiled /// [Permissions(DenyWrite = true, ReadRight = DreamDaemonRights.ReadRevision)] - public DateTimeOffset CompiledAt { get; set; } - - /// - /// If starts when it's starts - /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetAutoStart)] - public bool? AutoStart { get; set; } + public CompileJob CompileJob { get; set; } /// /// The current status of @@ -47,45 +21,15 @@ namespace Tgstation.Server.Api.Models public DreamDaemonStatus? Status { get; set; } /// - /// If the BYOND web client can be used to connect to the game server + /// The current of /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetWebClient)] - public bool? AllowWebClient { get; set; } - - /// - /// If the server is undergoing a soft reset. This may be automatically set by changes to other fields - /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SoftRestart)] - public bool? SoftRestart { get; set; } - - /// - /// If the server is undergoing a soft shutdown - /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SoftShutdown)] - public bool? SoftShutdown { get; set; } - - /// - /// The level of - /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetSecurity)] - public DreamDaemonSecurity? SecurirtyLevel { get; set; } - - /// - /// The first port uses. This should be the publically advertised port - /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetPorts)] - public ushort? PrimaryPort { get; set; } - - /// - /// The second port uses - /// - [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetPorts)] - public ushort? SecondaryPort { get; set; } + [Permissions(DenyWrite = true, ReadRight = DreamDaemonRights.ReadMetadata)] + public DreamDaemonSecurity? CurrentSecurity { get; set; } /// /// The port the running instance is set to /// [Permissions(DenyWrite = true, ReadRight = DreamDaemonRights.ReadMetadata)] - public bool CurrentPort { get; set; } + public ushort? CurrentPort { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/DreamDaemonSecurity.cs b/src/Tgstation.Server.Api/Models/DreamDaemonSecurity.cs index fef6a8f20c..094332d1e4 100644 --- a/src/Tgstation.Server.Api/Models/DreamDaemonSecurity.cs +++ b/src/Tgstation.Server.Api/Models/DreamDaemonSecurity.cs @@ -8,7 +8,7 @@ /// /// Server is unrestricted in terms of file access and shell commands /// - Trusted = 0, + Trusted, /// /// Server will not be able to run shell commands or access files outside it's working directory /// diff --git a/src/Tgstation.Server.Api/Models/DreamMaker.cs b/src/Tgstation.Server.Api/Models/DreamMaker.cs index e8a093f5a6..de1d893846 100644 --- a/src/Tgstation.Server.Api/Models/DreamMaker.cs +++ b/src/Tgstation.Server.Api/Models/DreamMaker.cs @@ -1,4 +1,4 @@ -using System; +using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models @@ -6,60 +6,18 @@ namespace Tgstation.Server.Api.Models /// /// Represents the state of the DreamMaker compiler. Create action starts a new compile. Delete action cancels the current compile /// - [Model(RightsType.DreamMaker, ReadRight = DreamMakerRights.Read, CanCrud = true, RequiresInstance = true)] - public sealed class DreamMaker + public sealed class DreamMaker : DreamMakerSettings { /// - /// When the compilation started + /// The last ran /// [Permissions(DenyWrite = true)] - public DateTimeOffset StartedAt { get; set; } - /// - /// When the compilation finished - /// - [Permissions(DenyWrite = true)] - public DateTimeOffset FinishedAt { get; set; } + public CompileJob LastJob { get; set; } + /// /// The of the compiler /// [Permissions(DenyWrite = true)] public CompilerStatus Status { get; set; } - /// - /// If the compiler targeted the primary directory - /// - [Permissions(DenyWrite = true)] - public bool? TargetedPrimaryDirectory { get; set; } - /// - /// Textual output of DM - /// - [Permissions(DenyWrite = true)] - public string Output { get; set; } - /// - /// Exit code of DM - /// - [Permissions(DenyWrite = true)] - public int? ExitCode { get; set; } - /// - /// Git revision the compiler ran on. Not modifiable - /// - [Permissions(DenyWrite = true)] - public string Revision { get; set; } - /// - /// Git revision of the origin branch the compiler ran on. Not modifiable - /// - [Permissions(DenyWrite = true)] - public string OriginRevision { get; set; } - - /// - /// How often the automatically compiles in minutes - /// - [Permissions(WriteRight = DreamMakerRights.SetAutoCompile)] - public int? AutoCompileInterval { get; set; } - - /// - /// The .dme file tries to compile with - /// - [Permissions(WriteRight = DreamMakerRights.SetDme)] - public string TargetDme { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Instance.cs b/src/Tgstation.Server.Api/Models/Instance.cs index 43d3eea404..bde9f13522 100644 --- a/src/Tgstation.Server.Api/Models/Instance.cs +++ b/src/Tgstation.Server.Api/Models/Instance.cs @@ -1,4 +1,5 @@ -using Tgstation.Server.Api.Rights; +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models { @@ -6,7 +7,7 @@ namespace Tgstation.Server.Api.Models /// Metadata about a server instance /// [Model(RightsType.InstanceManager, CanCrud = true, CanList = true)] - public sealed class Instance + public class Instance { /// /// The id of the . Not modifiable @@ -18,12 +19,14 @@ namespace Tgstation.Server.Api.Models /// The name of the /// [Permissions(WriteRight = InstanceManagerRights.Rename)] + [Required] public string Name { get; set; } /// - /// The path to where the is located + /// The path to where the is located. Changing this will temporarily offline the while it moves /// [Permissions(WriteRight = InstanceManagerRights.Relocate)] + [Required] public string Path { get; set; } /// @@ -31,5 +34,11 @@ namespace Tgstation.Server.Api.Models /// [Permissions(WriteRight = InstanceManagerRights.SetOnline)] public bool Online { get; set; } + + /// + /// If can be used on the + /// + [Permissions(WriteRight = InstanceManagerRights.SetConfiguration)] + public bool ConfigurationAllowed { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/InstanceUser.cs b/src/Tgstation.Server.Api/Models/InstanceUser.cs index a3e31e1dad..2173fc2681 100644 --- a/src/Tgstation.Server.Api/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Api/Models/InstanceUser.cs @@ -6,14 +6,8 @@ namespace Tgstation.Server.Api.Models /// Represents a s permissions in an /// [Model(RightsType.InstanceUser, WriteRight = InstanceUserRights.WriteUsers, CanList = true, RequiresInstance = true)] - public sealed class InstanceUser + public class InstanceUser { - /// - /// See definition in - /// - [Permissions(DenyWrite = true)] - public long Id { get; set; } - /// /// The of the /// @@ -35,9 +29,9 @@ namespace Tgstation.Server.Api.Models public RepositoryRights RepositoryRights { get; set; } /// - /// The of the + /// The of the /// - public ChatRights ChatRights { get; set; } + public ChatSettingsRights ChatSettingsRights { get; set; } /// /// The of the diff --git a/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs b/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs new file mode 100644 index 0000000000..a672895d3c --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Manage the server chat bots + /// + [Model(RightsType.ChatSettings, RequiresInstance = true)] + public class ChatSettings + { + /// + /// If the IRC client is enabled + /// + [Permissions(WriteRight = ChatSettingsRights.SetIrcEnabled)] + public bool IrcEnabled { get; set; } + + /// + /// The IRC server name + /// + [Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)] + [Required] + public string IrcHost { get; set; } + + /// + /// The IRC server port + /// + [Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)] + public ushort IrcPort { get; set; } + + /// + /// The IRC server NickServ password + /// + [Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)] + public string IrcNickServPassword { get; set; } + + /// + /// If the Discord bot is enabled + /// + [Permissions(WriteRight = ChatSettingsRights.SetDiscordEnabled)] + public bool DiscordEnabled { get; set; } + + /// + /// The Discord bot token + /// + [Permissions(ReadRight = ChatSettingsRights.SetDiscordSettings, WriteRight = ChatSettingsRights.SetDiscordSettings)] + public string DiscordBotToken { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs new file mode 100644 index 0000000000..9fccf5e173 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs @@ -0,0 +1,42 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents a run of + /// + public class CompileJob + { + /// + /// The ID of the job + /// + public long Id { get; set; } + + /// + /// When the compilation started + /// + [Required] + public DateTimeOffset StartedAt { get; set; } + + /// + /// When the compilation finished + /// + public DateTimeOffset FinishedAt { get; set; } + + /// + /// If the compiler targeted the primary directory + /// + public bool? TargetedPrimaryDirectory { get; set; } + + /// + /// Textual output of DM + /// + public string Output { get; set; } + + /// + /// Exit code of DM. If , the job was cancelled + /// + public int? ExitCode { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/DreamDaemonSettings.cs b/src/Tgstation.Server.Api/Models/Internal/DreamDaemonSettings.cs new file mode 100644 index 0000000000..6b0520c59e --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/DreamDaemonSettings.cs @@ -0,0 +1,53 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Configurable settings for + /// + [Model(RightsType.DreamDaemon, CanCrud = true, RequiresInstance = true)] + public class DreamDaemonSettings + { + /// + /// If starts when it's starts + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetAutoStart)] + public bool AutoStart { get; set; } + + /// + /// If the BYOND web client can be used to connect to the game server + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetWebClient)] + public bool AllowWebClient { get; set; } + + /// + /// If the server is undergoing a soft reset. This may be automatically set by changes to other fields + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SoftRestart)] + public bool SoftRestart { get; set; } + + /// + /// If the server is undergoing a soft shutdown + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SoftShutdown)] + public bool SoftShutdown { get; set; } + + /// + /// The level of + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetSecurity)] + public DreamDaemonSecurity SecurityLevel { get; set; } + + /// + /// The first port uses. This should be the publically advertised port + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetPorts)] + public ushort PrimaryPort { get; set; } + + /// + /// The second port uses + /// + [Permissions(ReadRight = DreamDaemonRights.ReadMetadata, WriteRight = DreamDaemonRights.SetPorts)] + public ushort SecondaryPort { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs b/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs new file mode 100644 index 0000000000..8d31ef3ef0 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs @@ -0,0 +1,23 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Configurable settings for + /// + [Model(RightsType.DreamMaker, ReadRight = DreamMakerRights.Read, CanCrud = true, RequiresInstance = true)] + public class DreamMakerSettings + { + /// + /// How often the automatically compiles in minutes + /// + [Permissions(WriteRight = DreamMakerRights.SetAutoCompile)] + public int? AutoCompileInterval { get; set; } + + /// + /// The .dme file tries to compile with + /// + [Permissions(WriteRight = DreamMakerRights.SetDme)] + public string TargetDme { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/Job.cs b/src/Tgstation.Server.Api/Models/Internal/Job.cs new file mode 100644 index 0000000000..b8ebe63b70 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/Job.cs @@ -0,0 +1,44 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents a long running job + /// + [Model(RequiresInstance = true)] + public class Job + { + /// + /// The ID + /// + [Permissions(DenyWrite = true)] + public long Id { get; set; } + + /// + /// English description of the + /// + [Permissions(DenyWrite = true)] + [Required] + public string Description { get; set; } + + /// + /// When the was started + /// + [Permissions(DenyWrite = true)] + [Required] + public DateTimeOffset StartedAt { get; set; } + + /// + /// When the stopped + /// + [Permissions(DenyWrite = true)] + public DateTimeOffset StoppedAt { get; set; } + + /// + /// If the was cancelled + /// + [Permissions(DenyWrite = true)] + public bool Cancelled { get; set; } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs b/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs new file mode 100644 index 0000000000..11917d2f81 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/RepositorySettings.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents configurable settings for a + /// + [Model(RightsType.Repository, ReadRight = RepositoryRights.Read, RequiresInstance = true)] + public class RepositorySettings + { + /// + /// The name of the committer + /// + [Permissions(WriteRight = RepositoryRights.ChangeCommitter)] + [Required] + public string CommitterName { get; set; } + + /// + /// The e-mail of the committer + /// + [Permissions(WriteRight = RepositoryRights.ChangeCommitter)] + [Required] + public string CommitterEmail { get; set; } + + /// + /// The username to access the git repository with + /// + [Permissions(ReadRight = RepositoryRights.ChangeCredentials, WriteRight = RepositoryRights.ChangeCredentials)] + public string AccessUser { get; set; } + + /// + /// The token/password to access the git repository with + /// + [Permissions(ReadRight = RepositoryRights.ChangeCredentials, WriteRight = RepositoryRights.ChangeCredentials)] + public string AccessToken { get; set; } + + /// + /// If commits created from testmerges are pushed to the remote + /// + [Permissions(WriteRight = RepositoryRights.ChangeTestMergeCommits)] + public bool PushTestMergeCommits { get; set; } + + /// + /// How often the automatically updates in minutes + /// + [Permissions(WriteRight = RepositoryRights.ChangeAutoUpdate)] + public int? AutoUpdateInterval { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/RevisionInformation.cs b/src/Tgstation.Server.Api/Models/Internal/RevisionInformation.cs new file mode 100644 index 0000000000..44eb907a89 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/RevisionInformation.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents information about a current git revison + /// + public class RevisionInformation + { + /// + /// The revision sha + /// + [Permissions(DenyWrite = true)] + [Required, StringLength(40)] + public string Revision { get; set; } + + /// + /// The sha of the most recent remote commit + /// + [Permissions(DenyWrite = true)] + [Required, StringLength(40)] + public string OriginRevision { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/ServerSettings.cs b/src/Tgstation.Server.Api/Models/Internal/ServerSettings.cs new file mode 100644 index 0000000000..f044801219 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/ServerSettings.cs @@ -0,0 +1,29 @@ +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Metadata about an installation + /// + [Model(RightsType.Administration)] + public class ServerSettings + { + /// + /// Use the specified Windows/POSIX authentication group to authorize users. Changing this may enable or disable s depending on how they were configured. Setting this to changes the authentication mode to database. + /// + [Permissions(ReadRight = AdministrationRights.ChangeAuthenticationGroup, WriteRight = AdministrationRights.ChangeAuthenticationGroup)] + public string SystemAuthenticationGroup { get; set; } + + /// + /// Automatically send unhandled exception data to a public collection service. This will be limited to system information, path data, and game code compilation information. + /// + [Permissions(ReadRight = AdministrationRights.ChangeTelemetry, WriteRight = AdministrationRights.ChangeTelemetry)] + public bool EnableTelemetry { get; set; } + + /// + /// The git repository to recieve updates to Tgstation.Server.Host from, must include credentials if necessary. If set to upstream pulls will be disabled entirely + /// + [Permissions(ReadRight = AdministrationRights.SetUpstreamRepository, WriteRight = AdministrationRights.SetUpstreamRepository)] + public string UpstreamRepository { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs b/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs new file mode 100644 index 0000000000..9e1d04f64d --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/TestMerge.cs @@ -0,0 +1,40 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents a merge of a GitHub pull request + /// + public class TestMerge : TestMergeParameters + { + /// + /// The ID of the + /// + public long Id { get; set; } + + /// + /// When the was created + /// + [Required] + public DateTimeOffset MergedAt { get; set; } + + /// + /// The title of the pull request + /// + [Required] + public string TitleAtMerge { get; set; } + + /// + /// The body of the pull request + /// + [Required] + public string BodyAtMerge { get; set; } + + /// + /// The author of the pull request + /// + [Required] + public string Author { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/Internal/User.cs b/src/Tgstation.Server.Api/Models/Internal/User.cs new file mode 100644 index 0000000000..e8c137c04a --- /dev/null +++ b/src/Tgstation.Server.Api/Models/Internal/User.cs @@ -0,0 +1,57 @@ +using System; +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Api.Models.Internal +{ + /// + /// Represents a server + /// + [Model(RightsType.Administration, WriteRight = AdministrationRights.EditUsers, CanCrud = true)] + public class User + { + /// + /// The ID of the + /// + [Permissions(DenyWrite = true)] + public long Id { get; set; } + + /// + /// If the is enabled since users cannot be deleted. System users cannot be disabled + /// + [Permissions(WriteRight = AdministrationRights.EditUsers)] + public bool Enabled { get; set; } + + /// + /// When the was created + /// + [Permissions(DenyWrite = true)] + [Required] + public DateTimeOffset CreatedAt { get; set; } + + /// + /// The SID/UID of the on Windows/POSIX respectively + /// + [Permissions(DenyWrite = true)] + public string SystemIdentifier { get; set; } + + /// + /// The name of the + /// + [Permissions(WriteRight = AdministrationRights.EditUsers)] + [Required] + public string Name { get; set; } + + /// + /// The for the + /// + [Permissions(WriteRight = AdministrationRights.EditUsers)] + public AdministrationRights AdministrationRights { get; set; } + + /// + /// The for the + /// + [Permissions(WriteRight = AdministrationRights.EditUsers)] + public InstanceManagerRights InstanceManagerRights { get; set; } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Models/Job.cs b/src/Tgstation.Server.Api/Models/Job.cs index d0231963b4..ebbf23bb21 100644 --- a/src/Tgstation.Server.Api/Models/Job.cs +++ b/src/Tgstation.Server.Api/Models/Job.cs @@ -1,46 +1,26 @@ -using System; - -namespace Tgstation.Server.Api.Models +namespace Tgstation.Server.Api.Models { /// /// Represents a long running job on the server. Model is read-only, updates attempt to cancel the job /// - [Model] - public sealed class Job + public sealed class Job : Internal.Job { - /// - /// The ID - /// - public long Id { get; set; } - - /// - /// Human readable description of the - /// - public string Description { get; set; } - - /// - /// When the was started - /// - public DateTimeOffset StartedAt { get; set; } - - /// - /// When the stopped - /// - public DateTimeOffset StoppedAt { get; set; } - - /// - /// If the was cancelled - /// - public bool Cancelled { get; set; } - /// /// If the has incremental progress, this will range from 1 - 100. 0 otherwise /// + [Permissions(DenyWrite = true)] public int Progress { get; set; } /// - /// If the current user has permission to cancel the job. Will be if isn't + /// If the current user has permission to cancel the job /// - public bool? UserCanCancel { get; set; } + [Permissions(DenyWrite = true)] + public bool UserCanCancel { get; set; } + + /// + /// The that started the job + /// + [Permissions(DenyWrite = true)] + public User StartedBy { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Repository.cs b/src/Tgstation.Server.Api/Models/Repository.cs index 57c1a14f7d..96ca42c58c 100644 --- a/src/Tgstation.Server.Api/Models/Repository.cs +++ b/src/Tgstation.Server.Api/Models/Repository.cs @@ -6,8 +6,7 @@ namespace Tgstation.Server.Api.Models /// /// Represents a git repository /// - [Model(RightsType.Repository, ReadRight = RepositoryRights.Read, RequiresInstance = true)] - public sealed class Repository + public sealed class Repository : Internal.RepositorySettings { /// /// The origin URL. If , the does not exist @@ -19,7 +18,13 @@ namespace Tgstation.Server.Api.Models /// The commit HEAD points to /// [Permissions(WriteRight = RepositoryRights.SetSha)] - public string Sha { get; set; } + public string NewRevision { get; set; } + + /// + /// The current for the + /// + [Permissions(DenyWrite = true)] + public RevisionInformation RevisionInformation { get; set; } /// /// The branch or tag HEAD points to @@ -28,51 +33,9 @@ namespace Tgstation.Server.Api.Models public string Reference { get; set; } /// - /// The name of the committer - /// - [Permissions(WriteRight = RepositoryRights.ChangeCommitter)] - public string CommitterName { get; set; } - - /// - /// The e-mail of the committer - /// - [Permissions(WriteRight = RepositoryRights.ChangeCommitter)] - public string CommitterEmail { get; set; } - - /// - /// Associated list of tag name -> sha for repository compiles. Not modifiable - /// - [Permissions(DenyWrite = true)] - public IReadOnlyDictionary Backups { get; set; } - - /// - /// Associated list of GitHub pull request number -> sha for merged pull requests. Adding a value to this list will merge the latest commit of the pull request numbered by the key + /// for new s /// [Permissions(WriteRight = RepositoryRights.MergePullRequest)] - public Dictionary PullRequests { get; set; } - - /// - /// The username to access the git repository with - /// - [Permissions(ReadRight = RepositoryRights.ChangeCredentials, WriteRight = RepositoryRights.ChangeCredentials)] - public string AccessUser { get; set; } - - /// - /// The token/password to access the git repository with - /// - [Permissions(ReadRight = RepositoryRights.ChangeCredentials, WriteRight = RepositoryRights.ChangeCredentials)] - public string AccessToken { get; set; } - - /// - /// If commits created from testmerges are pushed to the remote - /// - [Permissions(WriteRight = RepositoryRights.ChangeTestMergeCommits)] - public bool PushTestMergeCommits { get; set; } - - /// - /// How often the automatically updates in minutes - /// - [Permissions(WriteRight = RepositoryRights.ChangeAutoUpdate)] - public int? AutoUpdateInterval { get; set; } + public List NewTestMerges { get; set; } } } \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Models/RevisionInformation.cs b/src/Tgstation.Server.Api/Models/RevisionInformation.cs new file mode 100644 index 0000000000..0c769f02e7 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/RevisionInformation.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Api.Models +{ + /// + public sealed class RevisionInformation : Internal.RevisionInformation + { + /// + /// The s active in the + /// + public List TestMerges { get; set; } + } +} diff --git a/src/Tgstation.Server.Api/Models/TestMerge.cs b/src/Tgstation.Server.Api/Models/TestMerge.cs new file mode 100644 index 0000000000..d448504e2d --- /dev/null +++ b/src/Tgstation.Server.Api/Models/TestMerge.cs @@ -0,0 +1,11 @@ +namespace Tgstation.Server.Api.Models +{ + /// + public sealed class TestMerge : Internal.TestMerge + { + /// + /// The who created the + /// + public User MergedBy { get; set; } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Models/TestMergeParameters.cs b/src/Tgstation.Server.Api/Models/TestMergeParameters.cs new file mode 100644 index 0000000000..2c3ba33f02 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/TestMergeParameters.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Api.Models +{ + /// + /// Parameters for creating a + /// + public class TestMergeParameters + { + /// + /// The number of the pull request + /// + public int Number { get; set; } + + /// + /// The sha of the pull request revision to merge + /// + [Required] + public string PullRequestRevision { get; set; } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Models/Token.cs b/src/Tgstation.Server.Api/Models/Token.cs index e970114954..9ec9a5edd5 100644 --- a/src/Tgstation.Server.Api/Models/Token.cs +++ b/src/Tgstation.Server.Api/Models/Token.cs @@ -1,38 +1,13 @@ -using System; -using System.Net; -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Api.Models +namespace Tgstation.Server.Api.Models { /// - /// Represents an access token for the server. Read action generates a new one. Update action expires all for user + /// Represents a JWT returned by the API /// - [Model(RightsType.Token, CanList = true)] public sealed class Token { /// - /// The id of the . Not modifiable + /// The value of the JWT /// - public long Id { get; } - - /// - /// The user agent that created the - /// - public string ClientUserAgent { get; set; } - - /// - /// The the was originally issued to - /// - public IPAddress IssuedTo { get; set; } - - /// - /// When the was originally issued - /// - public DateTimeOffset IssuedAt { get; set; } - - /// - /// The token . Not modifiable, only appears once - /// - public string Value { get; set; } + public string Bearer { get; set; } } -} \ No newline at end of file +} diff --git a/src/Tgstation.Server.Api/Models/User.cs b/src/Tgstation.Server.Api/Models/User.cs index fe50e0bc77..83fac4e82f 100644 --- a/src/Tgstation.Server.Api/Models/User.cs +++ b/src/Tgstation.Server.Api/Models/User.cs @@ -1,39 +1,12 @@ -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Api.Models +namespace Tgstation.Server.Api.Models { - /// - /// Represents a server - /// - [Model(RightsType.Administration, WriteRight = AdministrationRights.EditUsers)] - public sealed class User + /// + public sealed class User : Internal.User { /// - /// The ID of the + /// The who created this /// [Permissions(DenyWrite = true)] - public long Id { get; set; } - - /// - /// The SID/UID of the on Windows/POSIX respectively - /// - [Permissions(DenyWrite = true)] - public string SystemId { get; set; } - - /// - /// The name of the - /// - [Permissions(DenyWrite = true)] - public string Name { get; set; } - - /// - /// The for the - /// - public AdministrationRights AdministrationRights { get; set; } - - /// - /// The for the - /// - public InstanceManagerRights InstanceManagerRights { get; set; } + public User CreatedBy { get; set; } } } \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs index 5a9a37aaf8..bc7d302397 100644 --- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs +++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs @@ -13,11 +13,11 @@ namespace Tgstation.Server.Api.Rights /// None = 0, /// - /// User can change + /// User can change /// ChangeAuthenticationGroup = 1, /// - /// User can change + /// User can change /// ChangeTelemetry = 2, /// @@ -33,9 +33,8 @@ namespace Tgstation.Server.Api.Rights /// ChangeVersion = 16, /// - /// User can change + /// User can change /// - SetUpstreamRepository - + SetUpstreamRepository = 32 } } diff --git a/src/Tgstation.Server.Api/Rights/ChatRights.cs b/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs similarity index 72% rename from src/Tgstation.Server.Api/Rights/ChatRights.cs rename to src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs index 30fb36787a..d27723c28c 100644 --- a/src/Tgstation.Server.Api/Rights/ChatRights.cs +++ b/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs @@ -3,10 +3,10 @@ namespace Tgstation.Server.Api.Rights { /// - /// Rights for + /// Rights for /// [Flags] - public enum ChatRights + public enum ChatSettingsRights { /// /// User has no rights @@ -21,9 +21,9 @@ namespace Tgstation.Server.Api.Rights /// SetIrcSettings = 2, /// - /// User can change the IRC channels + /// User can change the chat channels /// - SetIrcChannels = 4, + SetChannels = 4, /// /// User can enable/disable the Discord bot /// @@ -32,9 +32,5 @@ namespace Tgstation.Server.Api.Rights /// User can change the Discord settings /// SetDiscordSettings = 16, - /// - /// User can change the Discord channels - /// - SetDiscordChannels = 32, } } diff --git a/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs b/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs index 51591eb334..f99f63cd58 100644 --- a/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs +++ b/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs @@ -13,7 +13,7 @@ namespace Tgstation.Server.Api.Rights /// None = 0, /// - /// User can read , , and + /// User can read /// ReadRevision = 1, /// @@ -21,27 +21,27 @@ namespace Tgstation.Server.Api.Rights /// SetPorts = 2, /// - /// User can change + /// User can change /// SetAutoStart = 4, /// - /// User set + /// User set /// SetSecurity = 8, /// - /// User can read all ports, , , , , and + /// User can read all ports, , , , , and /// ReadMetadata = 16, /// - /// User can change + /// User can change /// SetWebClient = 32, /// - /// User can enable + /// User can enable /// SoftRestart = 64, /// - /// User can enable + /// User can enable /// SoftShutdown = 128, /// @@ -53,7 +53,7 @@ namespace Tgstation.Server.Api.Rights /// Shutdown = 512, /// - /// User can start and disable and + /// User can start and disable and /// Start = 1024 } diff --git a/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs b/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs index f770f8c01e..809816dbf9 100644 --- a/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs +++ b/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs @@ -25,11 +25,11 @@ namespace Tgstation.Server.Api.Rights /// CancelCompile = 4, /// - /// User may modify + /// User may modify /// SetAutoCompile = 8, /// - /// User may modify + /// User may modify /// SetDme = 16 } diff --git a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs index a00d4ffedc..106eaa1ad1 100644 --- a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs +++ b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs @@ -39,6 +39,10 @@ namespace Tgstation.Server.Api.Rights /// /// User can view all s /// - List = 64 + List = 64, + /// + /// User can change + /// + SetConfiguration = 128 } } diff --git a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs index 80339d9d7f..c418507036 100644 --- a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs +++ b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs @@ -29,19 +29,19 @@ namespace Tgstation.Server.Api.Rights /// UpdateBranch = 16, /// - /// User may change and + /// User may change and /// ChangeCommitter = 32, /// - /// User may change + /// User may change /// ChangeTestMergeCommits = 64, /// - /// User may change + /// User may change /// ChangeAutoUpdate = 128, /// - /// User may read and change and + /// User may read and change and /// ChangeCredentials = 256, /// @@ -49,7 +49,7 @@ namespace Tgstation.Server.Api.Rights /// SetReference = 512, /// - /// User may read all fields in the with the exception of + /// User may read all fields in the with the exception of /// Read = 1024, } diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index 1dcefa440e..ab4e16de4e 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -15,13 +15,12 @@ namespace Tgstation.Server.Api.Rights { { RightsType.Administration, typeof(AdministrationRights) }, { RightsType.InstanceManager, typeof(InstanceManagerRights) }, - { RightsType.Token, typeof(TokenRights) }, { RightsType.Repository, typeof(RepositoryRights) }, { RightsType.Byond, typeof(ByondRights) }, { RightsType.DreamMaker, typeof(DreamMakerRights) }, { RightsType.DreamDaemon, typeof(DreamDaemonRights) }, - { RightsType.Chat, typeof(ChatRights) }, + { RightsType.ChatSettings, typeof(ChatSettingsRights) }, { RightsType.Configuration, typeof(ConfigurationRights) }, { RightsType.InstanceUser, typeof(InstanceUserRights) } }; @@ -31,6 +30,33 @@ namespace Tgstation.Server.Api.Rights /// /// The to lookup /// The of the given - public static Type TypeToRight(RightsType rightsType) => typeMap[rightsType]; + public static Type RightToType(RightsType rightsType) => typeMap[rightsType]; + + /// + /// Gets the role claim name used for a given + /// + /// The + /// The + /// A representing the claim role name + public static string RoleName(TRight right) => String.Concat(typeof(TRight).Name, '.', right.ToString()); + + /// + /// Gets the role claim name used for a given and + /// + /// The + /// The right value + /// A representing the claim role name + public static string RoleName(RightsType rightsType, Enum right) + { + var enumType = typeMap[rightsType]; + return String.Concat(enumType.Name, '.', Enum.GetName(enumType, right)); + } + + /// + /// Check if a given is meant for an + /// + /// The to check + /// if is an instance right, otherwise + public static bool IsInstanceRight(RightsType rightsType) => !(rightsType == RightsType.Administration || rightsType == RightsType.InstanceManager); } } diff --git a/src/Tgstation.Server.Api/Rights/RightsType.cs b/src/Tgstation.Server.Api/Rights/RightsType.cs index b2659f8817..7ac7491661 100644 --- a/src/Tgstation.Server.Api/Rights/RightsType.cs +++ b/src/Tgstation.Server.Api/Rights/RightsType.cs @@ -13,11 +13,6 @@ /// /// InstanceManager, - /// - /// - /// - Token, - /// /// /// @@ -35,9 +30,9 @@ /// DreamDaemon, /// - /// + /// /// - Chat, + ChatSettings, /// /// /// diff --git a/src/Tgstation.Server.Api/Rights/TokenRights.cs b/src/Tgstation.Server.Api/Rights/TokenRights.cs deleted file mode 100644 index 98f5bf3f03..0000000000 --- a/src/Tgstation.Server.Api/Rights/TokenRights.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; - -namespace Tgstation.Server.Api.Rights -{ - /// - /// Rights for s - /// - [Flags] - public enum TokenRights - { - /// - /// User has no rights - /// - None = 0, - /// - /// User can create s - /// - Create = 1, - /// - /// User can delete s - /// - Delete = 2, - /// - /// User can list all their s - /// - List = 4, - /// - /// User can perform all the actions they can for themselves on behalf of other users except - /// - Admin = 8, - } -} diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index 942c0a5227..3da598cb1b 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -10,16 +10,18 @@ true bin\Release\netstandard2.0\Tgstation.Server.Api.xml - 1701;1702;1705;CA2227 + 1701;1702;1705;CA2227;CA1819 latest - 1701;1702;1705;CA2227 + 1701;1702;1705;CA2227;CA1819 + + diff --git a/src/Tgstation.Server.Client/Components/IChatClient.cs b/src/Tgstation.Server.Client/Components/IChatSettingsClient.cs similarity index 55% rename from src/Tgstation.Server.Client/Components/IChatClient.cs rename to src/Tgstation.Server.Client/Components/IChatSettingsClient.cs index 4699395a6f..d8ad100b18 100644 --- a/src/Tgstation.Server.Client/Components/IChatClient.cs +++ b/src/Tgstation.Server.Client/Components/IChatSettingsClient.cs @@ -8,21 +8,21 @@ namespace Tgstation.Server.Client.Components /// /// For managing the chat bots /// - public interface IChatClient : IRightsClient + public interface IChatSettingsClient : IRightsClient { /// - /// Get the represented by the + /// Get the represented by the /// /// The for the operation - /// A resulting in the represented by the - Task Read(CancellationToken cancellationToken); + /// A resulting in the represented by the + Task Read(CancellationToken cancellationToken); /// - /// Updates the setttings + /// Updates the setttings /// - /// The to update + /// The to update /// The for the operation /// A representing the running operation - Task Update(Chat chat, CancellationToken cancellationToken); + Task Update(ChatSettings chat, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs b/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs index 228055c9c2..1947c4170d 100644 --- a/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs @@ -32,7 +32,7 @@ namespace Tgstation.Server.Client.Components Task Shutdown(CancellationToken cancellationToken); /// - /// Update . This may trigger + /// Update . This may trigger /// /// The to update /// The for the operation diff --git a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs index 276f1d2258..ef1fb7b29b 100644 --- a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs @@ -15,7 +15,7 @@ namespace Tgstation.Server.Client.Components /// /// The for the operation /// A resulting in the represented by the - Task Read(CancellationToken cancellationToken); + Task Read(CancellationToken cancellationToken); /// /// Updates the setttings diff --git a/src/Tgstation.Server.Client/Components/IInstanceClient.cs b/src/Tgstation.Server.Client/Components/IInstanceClient.cs index baebd33d91..36b5b605f5 100644 --- a/src/Tgstation.Server.Client/Components/IInstanceClient.cs +++ b/src/Tgstation.Server.Client/Components/IInstanceClient.cs @@ -35,9 +35,9 @@ namespace Tgstation.Server.Client.Components IInstanceUserClient Users { get; } /// - /// Access the + /// Access the /// - IChatClient Chat { get; } + IChatSettingsClient Chat { get; } /// /// Access the diff --git a/src/Tgstation.Server.Client/Components/ITokenClient.cs b/src/Tgstation.Server.Client/Components/ITokenClient.cs deleted file mode 100644 index 03965132a0..0000000000 --- a/src/Tgstation.Server.Client/Components/ITokenClient.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Client.Components -{ - /// - /// for managing s - /// - public interface ITokenClient: IRightsClient - { - /// - /// Generate a new for the connected user/ipaddress, deleting the current one if applicable - /// - /// The for the operation - /// A resulting in the new - Task Read(CancellationToken cancellationToken); - - /// - /// Gets all active s for the - /// - /// The for the operation - /// A resulting a of active s for the - Task> GetClientInfos(CancellationToken cancellationToken); - - /// - /// Gets all active s for all users - /// - /// The for the operation - /// A resulting a of active s for the keyed by username - Task> GetAllInfos(CancellationToken cancellationToken); - - /// - /// Invalidate the specified active - /// - /// The to invalidate - /// The for the operation - /// A representing the running operation - Task Invalidate(Token token, CancellationToken cancellationToken); - - /// - /// Invalidate all active s for a - /// - /// The for the operation - /// A representing the running operation - Task InvalidateAllClient(CancellationToken cancellationToken); - - /// - /// Invalidate all active s - /// - /// The for the operation - /// A representing the running operation - Task InvalidateAll(CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Client/IServerClient.cs b/src/Tgstation.Server.Client/IServerClient.cs index aa0fb7cc39..510d609aeb 100644 --- a/src/Tgstation.Server.Client/IServerClient.cs +++ b/src/Tgstation.Server.Client/IServerClient.cs @@ -30,11 +30,6 @@ namespace Tgstation.Server.Client /// int RequeryRate { get; set; } - /// - /// Access the - /// - ITokenClient Tokens { get; } - /// /// Access the /// diff --git a/src/Tgstation.Server.Client/IServerClientFactory.cs b/src/Tgstation.Server.Client/IServerClientFactory.cs index fb79446eb0..b3b3ecb506 100644 --- a/src/Tgstation.Server.Client/IServerClientFactory.cs +++ b/src/Tgstation.Server.Client/IServerClientFactory.cs @@ -24,7 +24,7 @@ namespace Tgstation.Server.Client /// Create a /// /// The URL to access tgstation-server at - /// The to access the API with + /// The to access the API with /// The initial timeout for the connection /// The for the operation /// A resulting in a new diff --git a/src/Tgstation.Server.Host.Console/Properties/launchSettings.json b/src/Tgstation.Server.Host.Console/Properties/launchSettings.json new file mode 100644 index 0000000000..5991274947 --- /dev/null +++ b/src/Tgstation.Server.Host.Console/Properties/launchSettings.json @@ -0,0 +1,21 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:51882/", + "sslPort": 0 + } + }, + "profiles": { + "Tgstation.Server.Host.Console": { + "commandName": "Project", + "workingDirectory": "bin\\Debug\\netcoreapp2.0", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:51885/" + } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host.Startup/IServer.cs b/src/Tgstation.Server.Host.Startup/IServer.cs index 8dad58bdcc..299e193036 100644 --- a/src/Tgstation.Server.Host.Startup/IServer.cs +++ b/src/Tgstation.Server.Host.Startup/IServer.cs @@ -7,19 +7,18 @@ namespace Tgstation.Server.Host.Startup /// /// Represents the host /// - public interface IServer : IDisposable + public interface IServer { /// - /// The path to the updated assembly to run if any. Populated once returns + /// The path to the updated assembly to run if any. Populated once returns /// string UpdatePath { get; } /// /// Runs the /// - /// The arguments for the /// The for the operation /// A representing the running operation - Task RunAsync(string[] args, CancellationToken cancellationToken); + Task RunAsync(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host.Startup/IServerFactory.cs b/src/Tgstation.Server.Host.Startup/IServerFactory.cs index 43668a1049..ed92399240 100644 --- a/src/Tgstation.Server.Host.Startup/IServerFactory.cs +++ b/src/Tgstation.Server.Host.Startup/IServerFactory.cs @@ -5,10 +5,11 @@ /// public interface IServerFactory { - /// - /// Create a - /// - /// A new - IServer CreateServer(); + /// + /// Create a + /// + /// The arguments for the + /// A new + IServer CreateServer(string[] args); } } diff --git a/src/Tgstation.Server.Host.Watchdog/IsolatedServerFactory.cs b/src/Tgstation.Server.Host.Watchdog/IsolatedServerFactory.cs index c7fe7f49cc..2545747721 100644 --- a/src/Tgstation.Server.Host.Watchdog/IsolatedServerFactory.cs +++ b/src/Tgstation.Server.Host.Watchdog/IsolatedServerFactory.cs @@ -25,8 +25,9 @@ namespace Tgstation.Server.Host.Watchdog /// /// Loads the at and creates an from it /// + /// The arguments for the /// A new - public IServer CreateServer() + public IServer CreateServer(string[] args) { var assembly = LoadFromAssemblyPath(assemblyPath); //find the IServerFactory implementation @@ -35,7 +36,7 @@ namespace Tgstation.Server.Host.Watchdog var serverFactoryImplementationType = assembly.GetTypes().Where(x => serverFactoryInterfaceType.IsAssignableFrom(x)).First(); var serverFactory = (IServerFactory)Activator.CreateInstance(serverFactoryImplementationType); - return serverFactory.CreateServer(); + return serverFactory.CreateServer(args); } //honestly have no idea what this is for, but the examples i see just return null and it seems to work just fine diff --git a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs index 540955d094..ef74711534 100644 --- a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs @@ -43,24 +43,19 @@ namespace Tgstation.Server.Host.Watchdog //first run the host we started with var serverFactory = initialServerFactory; var assemblyPath = serverFactory.GetType().Assembly.Location; - do - { - string updatePath; - using (var server = serverFactory.CreateServer()) - { - serverFactory = null; - await server.RunAsync(args, cancellationToken).ConfigureAwait(false); - updatePath = server.UpdatePath; - } + do + { + var server = serverFactory.CreateServer(args); + await server.RunAsync(cancellationToken).ConfigureAwait(false); - if (updatePath == null) - break; - - activeAssemblyDeleter.DeleteActiveAssembly(assemblyPath); - File.Move(updatePath, assemblyPath); - serverFactory = isolatedAssemblyLoader.CreateIsolatedServerFactory(assemblyPath); - } - while (!cancellationToken.IsCancellationRequested); + if (server.UpdatePath == null) + break; + + activeAssemblyDeleter.DeleteActiveAssembly(assemblyPath); + File.Move(server.UpdatePath, assemblyPath); + serverFactory = isolatedAssemblyLoader.CreateIsolatedServerFactory(assemblyPath); + } + while (!cancellationToken.IsCancellationRequested); } } } diff --git a/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs b/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs new file mode 100644 index 0000000000..4afebe14a5 --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs @@ -0,0 +1,23 @@ +namespace Tgstation.Server.Host.Configuration +{ + /// + /// Configuration options for the + /// + sealed class DatabaseConfiguration + { + /// + /// The key for the the resides in + /// + public const string Section = "Database"; + + /// + /// The to create + /// + public DatabaseType DatabaseType { get; set; } + + /// + /// The connection string for the database + /// + public string ConnectionString { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Configuration/DatabaseType.cs b/src/Tgstation.Server.Host/Configuration/DatabaseType.cs new file mode 100644 index 0000000000..bbc8be7798 --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/DatabaseType.cs @@ -0,0 +1,21 @@ +namespace Tgstation.Server.Host.Configuration +{ + /// + /// Type of database to user + /// + enum DatabaseType + { + /// + /// Use Microsoft SQL Server + /// + SqlServer, + /// + /// Use MySQL/MariaDB + /// + MySql, + /// + /// Use SQLite 3 + /// + Sqlite + } +} diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs new file mode 100644 index 0000000000..4c6bcd541f --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// A for API functions + /// + [Produces(ApiHeaders.ApplicationJson)] + [Consumes(ApiHeaders.ApplicationJson)] + public abstract class ApiController : Controller + { + /// + /// The for the operation + /// + protected ApiHeaders ApiHeaders { get; private set; } + + /// + /// The for the operation + /// + protected IDatabaseContext DatabaseContext { get; } + + /// + /// The for the operation + /// + protected IAuthenticationContext AuthenticationContext { get; } + + /// + /// The for the operation + /// + protected Instance Instance { get; } + + /// + /// Runs after a has been validated. Creates the for the + /// + /// The for the operation + /// A representing the running operation + public static async Task OnTokenValidated(TokenValidatedContext context) + { + var databaseContext = context.HttpContext.RequestServices.GetRequiredService(); + var authenticationContextFactory = context.HttpContext.RequestServices.GetRequiredService(); + + var userIdClaim = context.Principal.FindFirst(JwtRegisteredClaimNames.Sub); + + if (userIdClaim == default(Claim)) + throw new InvalidOperationException("Missing required claim!"); + + long userId; + try + { + userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to parse user ID!", e); + } + + ApiHeaders apiHeaders; + try + { + apiHeaders = new ApiHeaders(context.HttpContext.Request.GetTypedHeaders()); + } + catch + { + //let OnActionExecutionAsync handle the reponse + return; + } + + await authenticationContextFactory.CreateAuthenticationContext(userId, apiHeaders.InstanceId, context.HttpContext.RequestAborted).ConfigureAwait(false); + + var authenticationContext = authenticationContextFactory.CurrentAuthenticationContext; + + var enumerator = Enum.GetValues(typeof(RightsType)); + var claims = new List(); + foreach (RightsType I in enumerator) + { + var rightInt = authenticationContext.GetRight(I); + var rightEnum = RightsHelper.RightToType(I); + var right = (Enum)Enum.ToObject(rightEnum, authenticationContext.GetRight(I)); + foreach(Enum J in Enum.GetValues(rightEnum)) + if(right.HasFlag(J)) + claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(I, J))); + } + + context.Principal.AddIdentity(new ClaimsIdentity(claims)); + } + + /// + /// Construct an + /// + /// The value of + /// The for the + public ApiController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory) + { + DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + if (authenticationContextFactory == null) + throw new ArgumentNullException(nameof(authenticationContextFactory)); + AuthenticationContext = authenticationContextFactory.CurrentAuthenticationContext; + Instance = AuthenticationContext?.InstanceUser?.Instance; + } + + /// + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + //validate the headers + try + { + ApiHeaders = new ApiHeaders(Request.GetTypedHeaders()); + } + catch (InvalidOperationException e) + { + await BadRequest(new { message = e.Message }).ExecuteResultAsync(context).ConfigureAwait(false); + return; + } + + await base.OnActionExecutionAsync(context, next).ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs new file mode 100644 index 0000000000..d839f45017 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// Main for the + /// + [Route("/")] + public sealed class HomeController : ApiController + { + /// + /// The for the + /// + readonly ITokenFactory tokenFactory; + /// + /// The for the + /// + readonly ISystemIdentityFactory systemIdentityFactory; + /// + /// The for the + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// Construct a + /// + /// The for the + /// The for the + /// The value of + /// The value of + /// The value of + public HomeController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ITokenFactory tokenFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite) : base(databaseContext, authenticationContextFactory) + { + this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory)); + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + } + + /// + /// Returns the version of the + /// + /// + [TgsAuthorize] + [HttpGet] + public JsonResult Home() => Json(Application.Version); + + /// + /// Attempt to authenticate a using + /// + /// The for the operation + /// A resulting in the of the operation + [HttpPost] + public async Task CreateToken(CancellationToken cancellationToken) + { + if (ApiHeaders.IsTokenAuthentication) + return BadRequest(new { message = "Cannot create a token using another token!" }); + + var user = await DatabaseContext.Users.Where(x => x.Name == ApiHeaders.Username).Select(x => new User{ + Id = x.Id, + PasswordHash = x.PasswordHash, + SystemIdentifier = x.SystemIdentifier, + Enabled = x.Enabled + }).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + if (user == null) + return Unauthorized(); + + if (user.PasswordHash != null) + { + var originalHash = user.PasswordHash; + if (!cryptographySuite.CheckUserPassword(user, ApiHeaders.Password)) + return Unauthorized(); + if (user.PasswordHash != originalHash) + { + var updatedUser = new User + { + Id = user.Id, + PasswordHash = originalHash + }; + DatabaseContext.Users.Attach(updatedUser); + updatedUser.PasswordHash = user.PasswordHash; + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + } + } + else + try + { + var systemIdentity = systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password); + } + catch + { + return Unauthorized(); + } + + if (!user.Enabled) + return Forbid(); + + return Json(tokenFactory.CreateToken(user)); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/ModelController.cs b/src/Tgstation.Server.Host/Controllers/ModelController.cs new file mode 100644 index 0000000000..59d2b5c19a --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/ModelController.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Mvc; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// An representing a + /// + /// The model being represented + public abstract class ModelController : ApiController where TModel : class + { + /// + /// The of the + /// + protected static readonly ModelAttribute ModelAttribute = (ModelAttribute)typeof(TModel).GetCustomAttributes(typeof(ModelAttribute), true).First(); + + /// + /// Construct a + /// + /// The for the + /// The for the + public ModelController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory) : base(databaseContext, authenticationContextFactory) { } + + /// + /// Attempt to create a + /// + /// The being created + /// The for the operation + /// A resulting in the of the operation + [HttpPut] + public virtual Task Create([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); + + /// + /// Attempt to read a + /// + /// The for the operation + /// A resulting in the of the operation + [HttpGet] + public virtual Task Read(CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); + + /// + /// Attempt to update a + /// + /// The being updated + /// The for the operation + /// A resulting in the of the operation + [HttpPost] + public virtual Task Update([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); + + /// + /// Attempt to delete a + /// + /// The being deleted + /// The for the operation + /// A resulting in the of the operation + [HttpDelete] + public virtual Task Delete([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); + + /// + /// Attempt to list entries of the + /// + /// The for the operation + /// A resulting in the of the operation + [HttpGet("/List")] + public virtual Task List(CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound()); + } +} diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs new file mode 100644 index 0000000000..2cedce3c5e --- /dev/null +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -0,0 +1,158 @@ +using Cyberboss.AspNetCore.AsyncInitializer; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Reflection; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Core +{ + /// + /// Configures the ASP.NET Core web application + /// + sealed class Application + { + /// + /// The version of the + /// + public static readonly Version Version = Assembly.GetExecutingAssembly().GetName().Version; + + /// + /// The for the + /// + readonly IConfiguration configuration; + + /// + /// The for the + /// + readonly IHostingEnvironment hostingEnvironment; + + /// + /// Construct an + /// + /// The value of + /// The value of + public Application(IConfiguration configuration, IHostingEnvironment hostingEnvironment) + { + this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + } + + /// + /// Configure dependency injected services + /// + /// The to configure +#pragma warning disable CA1822 // Mark members as static + public void ConfigureServices(IServiceCollection services) +#pragma warning restore CA1822 // Mark members as static + { + if (services == null) + throw new ArgumentNullException(nameof(services)); + var workingDir = Environment.CurrentDirectory; + var databaseConfigurationSection = configuration.GetSection(DatabaseConfiguration.Section); + services.Configure(databaseConfigurationSection); + + services.AddOptions(); + + const string scheme = "JwtBearer"; + services.AddAuthentication((options) => + { + options.DefaultAuthenticateScheme = scheme; + options.DefaultChallengeScheme = scheme; + }).AddJwtBearer(scheme, jwtBearerOptions => + { + jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(TokenFactory.TokenSigningKey), + + ValidateIssuer = true, + ValidIssuer = TokenFactory.TokenIssuer, + + ValidateLifetime = true, + ValidateAudience = true, + ValidAudience = TokenFactory.TokenAudience, + + ClockSkew = TimeSpan.FromMinutes(5), + + RequireSignedTokens = true, + + RequireExpirationTime = true + }; + jwtBearerOptions.Events = new JwtBearerEvents + { + OnTokenValidated = ApiController.OnTokenValidated + }; + }); + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs + + services.AddMvc(); + + var databaseConfiguration = databaseConfigurationSection.Get(); + void ConfigureDatabase(DbContextOptionsBuilder builder) + { + if (hostingEnvironment.IsDevelopment()) + builder.EnableSensitiveDataLogging(); + }; + + switch (databaseConfiguration.DatabaseType) + { + case DatabaseType.MySql: + services.AddDbContext(ConfigureDatabase); + services.AddScoped(x => x.GetRequiredService()); + break; + case DatabaseType.Sqlite: + services.AddDbContext(ConfigureDatabase); + services.AddScoped(x => x.GetRequiredService()); + break; + case DatabaseType.SqlServer: + services.AddDbContext(ConfigureDatabase); + services.AddScoped(x => x.GetRequiredService()); + break; + default: + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid {0}!", nameof(DatabaseType))); + } + + services.AddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton, PasswordHasher>(); + services.AddSingleton(); + services.AddSingleton(); + } + + /// + /// Configure the + /// + /// The to configure + public void Configure(IApplicationBuilder applicationBuilder) + { + if (applicationBuilder == null) + throw new ArgumentNullException(nameof(applicationBuilder)); + + if (hostingEnvironment.IsDevelopment()) + applicationBuilder.UseDeveloperExceptionPage(); + + applicationBuilder.UseAsyncInitialization(async (cancellationToken) => + { + using (var scope = applicationBuilder.ApplicationServices.CreateScope()) + await scope.ServiceProvider.GetRequiredService().Initialize(cancellationToken).ConfigureAwait(false); + }); + + applicationBuilder.UseAuthentication(); + applicationBuilder.UseMvc(); + } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Core/IServerUpdateConsumer.cs b/src/Tgstation.Server.Host/Core/IServerUpdateConsumer.cs new file mode 100644 index 0000000000..9055b7206e --- /dev/null +++ b/src/Tgstation.Server.Host/Core/IServerUpdateConsumer.cs @@ -0,0 +1,14 @@ +namespace Tgstation.Server.Host.Core +{ + /// + /// Represents a service that may take an updated assembly and run it, stopping the current assembly in the process + /// + interface IServerUpdateConsumer + { + /// + /// Run a new assembly and stop the current one. This will likely trigger all active s + /// + /// The path to the new assembly + void ApplyUpdate(string updatePath); + } +} diff --git a/src/Tgstation.Server.Host/Models/ChatChannel.cs b/src/Tgstation.Server.Host/Models/ChatChannel.cs new file mode 100644 index 0000000000..8f57e1e885 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/ChatChannel.cs @@ -0,0 +1,21 @@ +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class ChatChannel : Api.Models.ChatChannel + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// The + /// + public long ChatSettingsId { get; set; } + + /// + /// The + /// + public ChatSettings ChatSettings { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/ChatSettings.cs b/src/Tgstation.Server.Host/Models/ChatSettings.cs new file mode 100644 index 0000000000..5d3df56af5 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/ChatSettings.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class ChatSettings : Api.Models.Internal.ChatSettings + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// The + /// + public long InstanceId { get; set; } + + /// + /// The parent + /// + [Required] + public Instance Instance { get; set; } + + /// + /// See + /// + public List Channels { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs new file mode 100644 index 0000000000..29bdac135c --- /dev/null +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class CompileJob : Api.Models.Internal.CompileJob + { + /// + /// See + /// + [Required] + public User TriggeredBy { get; set; } + + /// + /// See + /// + [Required] + public RevisionInformation RevisionInformation { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/DatabaseContext.cs b/src/Tgstation.Server.Host/Models/DatabaseContext.cs new file mode 100644 index 0000000000..4dc62319ba --- /dev/null +++ b/src/Tgstation.Server.Host/Models/DatabaseContext.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Configuration; +using ZNetCS.AspNetCore.Logging.EntityFrameworkCore; + +namespace Tgstation.Server.Host.Models +{ + /// + abstract class DatabaseContext : DbContext, IDatabaseContext where TParentContext : DbContext + { + /// + public DbSet ServerSettings { get; set; } + + /// + public DbSet Users { get; set; } + + /// + public DbSet Instances { get; set; } + + /// + /// The for s + /// + public DbSet Logs { get; set; } + + /// + /// The s in the + /// + public DbSet InstanceUsers { get; set; } + /// + /// The s in the + /// + public DbSet ChatChannels { get; set; } + /// + /// The s in the + /// + public DbSet ChatSettings { get; set; } + /// + /// The in the + /// + public DbSet DreamDaemonSettings { get; set; } + /// + /// The in the + /// + public DbSet DreamMakerSettings { get; set; } + /// + /// The s in the + /// + public DbSet CompileJobs { get; set; } + /// + /// The s in the + /// + public DbSet Jobs { get; set; } + /// + /// The s in the + /// + public DbSet TestMerges { get; set; } + /// + /// The s in the + /// + public DbSet RevisionInformations { get; set; } + /// + /// The in the + /// + public DbSet RepositorySettings { get; set; } + + /// + /// The connection string for the + /// + protected string ConnectionString => databaseConfiguration.ConnectionString; + + /// + /// The for the + /// + readonly DatabaseConfiguration databaseConfiguration; + /// + /// The for the + /// + readonly ILoggerFactory loggerFactory; + /// + /// The for the + /// + readonly IDatabaseSeeder databaseSeeder; + + /// + /// Construct a + /// + /// The for the + /// The containing the value of + /// The value of + /// The value of + public DatabaseContext(DbContextOptions dbContextOptions, IOptions databaseConfigurationOptions, ILoggerFactory loggerFactory, IDatabaseSeeder databaseSeeder) : base(dbContextOptions) + { + databaseConfiguration = databaseConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(databaseConfigurationOptions)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.databaseSeeder = databaseSeeder ?? throw new ArgumentNullException(nameof(databaseSeeder)); + } + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // build default model. + LogModelBuilderHelper.Build(modelBuilder.Entity()); + modelBuilder.Entity().ToTable(nameof(Logs)); + + modelBuilder.Entity().HasIndex(x => x.Revision).IsUnique(); + var user = modelBuilder.Entity(); + user.HasIndex(x => new { x.Name, x.SystemIdentifier }).IsUnique(); + user.HasOne(x => x.CreatedBy).WithMany(x => x.CreatedUsers).OnDelete(DeleteBehavior.Restrict); + var chatChannel = modelBuilder.Entity(); + chatChannel.HasIndex(x => new { x.ChatSettingsId, x.IrcChannel }).IsUnique(); + chatChannel.HasIndex(x => new { x.ChatSettingsId, x.DiscordChannelId }).IsUnique(); + } + + /// + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + optionsBuilder.UseLoggerFactory(loggerFactory); + } + + /// + public async Task GetServerSettings(CancellationToken cancellationToken) + { + var settings = await ServerSettings.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (settings == default(ServerSettings)) + { + settings = new ServerSettings(); + ServerSettings.Add(settings); + } + return settings; + } + + /// + public async Task Initialize(CancellationToken cancellationToken) + { +#if DEBUG + await Database.EnsureCreatedAsync().ConfigureAwait(false); + var wasEmpty = (await Users.CountAsync().ConfigureAwait(false)) == 0; +#else + var migrations = await Database.GetAppliedMigrationsAsync().ConfigureAwait(false); + var wasEmpty = !migrations.Any(); + await Database.MigrateAsync(cancellationToken).ConfigureAwait(false); +#endif + if (wasEmpty) + await databaseSeeder.SeedDatabase(this, cancellationToken).ConfigureAwait(false); + } + + /// + public Task Save(CancellationToken cancellationToken) => SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs b/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs new file mode 100644 index 0000000000..7ccf4f33ca --- /dev/null +++ b/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Models +{ + /// + sealed class DatabaseSeeder : IDatabaseSeeder + { + /// + /// The default password mode admin password + /// + const string DefaultAdminPassword = "ISolemlySwearToDeleteTheDataDirectory"; + + /// + /// The default git repository to pull server updates from + /// + const string DefaultUpstreamRepository = "https://github.com/tgstation/tgstation-server"; + + /// + /// The for the + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// Construct a + /// + /// The value of + public DatabaseSeeder(ICryptographySuite cryptographySuite) => this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + + /// + public async Task SeedDatabase(IDatabaseContext databaseContext, CancellationToken cancellationToken) + { + var admin = new User + { + AdministrationRights = (AdministrationRights)~0, + CreatedAt = DateTimeOffset.Now, + InstanceManagerRights = (InstanceManagerRights)~0, + Name = "Admin", + Enabled = true, + }; + cryptographySuite.SetUserPassword(admin, DefaultAdminPassword); + databaseContext.Users.Add(admin); + + var serverSettings = await databaseContext.GetServerSettings(cancellationToken).ConfigureAwait(false); + + serverSettings.EnableTelemetry = true; + serverSettings.UpstreamRepository = DefaultUpstreamRepository; + + await databaseContext.Save(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Host/Models/DreamDaemonSettings.cs b/src/Tgstation.Server.Host/Models/DreamDaemonSettings.cs new file mode 100644 index 0000000000..6a1a550a42 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/DreamDaemonSettings.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class DreamDaemonSettings : Api.Models.Internal.DreamDaemonSettings + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// The PID of a currently running DD instance + /// + public int? ProcessId { get; set; } + + /// + /// The access token used for communication with DD + /// + public string AccessToken { get; set; } + + /// + /// The + /// + public long InstanceId { get; set; } + + /// + /// The parent + /// + [Required] + public Instance Instance { get; set; } + + /// + /// See + /// + public CompileJob CompileJob { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs new file mode 100644 index 0000000000..286be820a2 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/DreamMakerSettings.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class DreamMakerSettings : Api.Models.Internal.DreamMakerSettings + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// The + /// + public long InstanceId { get; set; } + + /// + /// The parent + /// + [Required] + public Instance Instance { get; set; } + + /// + /// + /// + public CompileJob LastJob { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/IDatabaseContext.cs b/src/Tgstation.Server.Host/Models/IDatabaseContext.cs new file mode 100644 index 0000000000..9ed9acf3a4 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IDatabaseContext.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents the database + /// + public interface IDatabaseContext + { + /// + /// The s in the + /// + DbSet Users { get; } + + /// + /// The s in the + /// + DbSet Instances { get; } + + /// + /// Get the in the + /// + /// The for the operation + /// A resulting in the in the + Task GetServerSettings(CancellationToken cancellationToken); + + /// + /// Saves changes made to the + /// + /// The for the operation + /// A representing the running operation + Task Save(CancellationToken cancellationToken); + + /// + /// Creates and migrates the + /// + /// The for the operation + /// A representing the running operation + Task Initialize(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Models/IDatabaseSeeder.cs b/src/Tgstation.Server.Host/Models/IDatabaseSeeder.cs new file mode 100644 index 0000000000..f57620fd0a --- /dev/null +++ b/src/Tgstation.Server.Host/Models/IDatabaseSeeder.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Models +{ + /// + /// For initially seeding a database + /// + interface IDatabaseSeeder + { + /// + /// Initially seed a given + /// + /// The to seed + /// The for the operation + /// A representing the running operation + Task SeedDatabase(IDatabaseContext databaseContext, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs new file mode 100644 index 0000000000..ca3a5bfad5 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Models +{ + /// + /// Represents an in the database + /// + public sealed class Instance : Api.Models.Instance + { + /// + /// The for the + /// + public ChatSettings ChatSettings { get; set; } + + /// + /// The for the + /// + public DreamMakerSettings DreamMakerSettings { get; set; } + + /// + /// The for the + /// + public DreamDaemonSettings DreamDaemonSettings { get; set; } + + /// + /// The for the + /// + public RepositorySettings RepositorySettings { get; set; } + + /// + /// The s in the + /// + public List InstanceUsers { get; set; } + + /// + /// The s in the + /// + public List TestMerges { get; set; } + + /// + /// The s in the + /// + public List CompileJobs { get; set; } + + /// + /// The in the + /// + public List Jobs { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/InstanceUser.cs b/src/Tgstation.Server.Host/Models/InstanceUser.cs new file mode 100644 index 0000000000..6566b2c891 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/InstanceUser.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class InstanceUser : Api.Models.InstanceUser + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// The the belongs to + /// + [Required] + public Instance Instance { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs new file mode 100644 index 0000000000..43d1fefba4 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class Job : Api.Models.Internal.Job + { + /// + /// See + /// + [Required] + public User StartedBy { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs b/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs new file mode 100644 index 0000000000..c633cc1ec4 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Migrations/DesignTimeDbContextFactoryHelpers.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.IO; +using System.Reflection; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Models.Migrations +{ + /// + /// Contains helpers for creating design time s + /// + static class DesignTimeDbContextFactoryHelpers + { + /// + /// Path to the json file to use for migrations configuration + /// + const string MigrationsJson = "appsettings.Development.json"; + + /// + public static IOptions GetDbContextOptions() + { + var builder = new ConfigurationBuilder(); + builder.SetBasePath(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); + builder.AddJsonFile(MigrationsJson); + var configuration = builder.Build(); + return Options.Create(configuration.GetSection(DatabaseConfiguration.Section).Get()); + } + } +} diff --git a/src/Tgstation.Server.Host/Models/Migrations/MySqlDesignTimeDbContextFactory.cs b/src/Tgstation.Server.Host/Models/Migrations/MySqlDesignTimeDbContextFactory.cs new file mode 100644 index 0000000000..b33b088ed2 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Migrations/MySqlDesignTimeDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Logging; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Models.Migrations +{ + /// + sealed class MySqlDesignTimeDbContextFactory : IDesignTimeDbContextFactory + { + /// + public MySqlDatabaseContext CreateDbContext(string[] args) => new MySqlDatabaseContext(new DbContextOptions(), DesignTimeDbContextFactoryHelpers.GetDbContextOptions(), new LoggerFactory(), new DatabaseSeeder(new CryptographySuite(new PasswordHasher()))); + } +} diff --git a/src/Tgstation.Server.Host/Models/Migrations/SqlServerDesignTimeDbContextFactory.cs b/src/Tgstation.Server.Host/Models/Migrations/SqlServerDesignTimeDbContextFactory.cs new file mode 100644 index 0000000000..935768080b --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Migrations/SqlServerDesignTimeDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Logging; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Models.Migrations +{ + /// + sealed class SqlServerDesignTimeDbContextFactory : IDesignTimeDbContextFactory + { + /// + public SqlServerDatabaseContext CreateDbContext(string[] args) => new SqlServerDatabaseContext(new DbContextOptions(), DesignTimeDbContextFactoryHelpers.GetDbContextOptions(), new LoggerFactory(), new DatabaseSeeder(new CryptographySuite(new PasswordHasher()))); + } +} diff --git a/src/Tgstation.Server.Host/Models/Migrations/SqliteDesignTimeDbContextFactory.cs b/src/Tgstation.Server.Host/Models/Migrations/SqliteDesignTimeDbContextFactory.cs new file mode 100644 index 0000000000..637bebfd0f --- /dev/null +++ b/src/Tgstation.Server.Host/Models/Migrations/SqliteDesignTimeDbContextFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Logging; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Models.Migrations +{ + /// + sealed class SqliteDesignTimeDbContextFactory : IDesignTimeDbContextFactory + { + /// + public SqliteDatabaseContext CreateDbContext(string[] args) => new SqliteDatabaseContext(new DbContextOptions(), DesignTimeDbContextFactoryHelpers.GetDbContextOptions(), new LoggerFactory(), new DatabaseSeeder(new CryptographySuite(new PasswordHasher()))); + } +} diff --git a/src/Tgstation.Server.Host/Models/MySqlDatabaseContext.cs b/src/Tgstation.Server.Host/Models/MySqlDatabaseContext.cs new file mode 100644 index 0000000000..e88678bf23 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/MySqlDatabaseContext.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Models +{ + /// + /// for MySQL + /// + sealed class MySqlDatabaseContext : DatabaseContext + { + /// + /// Construct a + /// + /// The for the + /// The of for the + /// The for the + /// The for the + public MySqlDatabaseContext(DbContextOptions dbContextOptions, IOptions databaseConfiguration, ILoggerFactory loggerFactory, IDatabaseSeeder databaseSeeder) : base(dbContextOptions, databaseConfiguration, loggerFactory, databaseSeeder) + { } + + /// + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + base.OnConfiguring(options); + options.UseMySQL(ConnectionString); + } + } +} diff --git a/src/Tgstation.Server.Host/Models/RepositorySettings.cs b/src/Tgstation.Server.Host/Models/RepositorySettings.cs new file mode 100644 index 0000000000..77e529f2ec --- /dev/null +++ b/src/Tgstation.Server.Host/Models/RepositorySettings.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class RepositorySettings : Api.Models.Internal.RepositorySettings + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// The + /// + public long InstanceId { get; set; } + + /// + /// The parent + /// + [Required] + public Instance Instance { get; set; } + + /// + /// See + /// + public RevisionInformation RevisionInformation { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/RevisionInformation.cs b/src/Tgstation.Server.Host/Models/RevisionInformation.cs new file mode 100644 index 0000000000..057cc7e680 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/RevisionInformation.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class RevisionInformation : Api.Models.Internal.RevisionInformation + { + /// + /// The row Id + /// + public long Id { get; set; } + + /// + /// See + /// + public List TestMerges { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/ServerSettings.cs b/src/Tgstation.Server.Host/Models/ServerSettings.cs new file mode 100644 index 0000000000..4326d9f563 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/ServerSettings.cs @@ -0,0 +1,11 @@ +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class ServerSettings : Api.Models.Internal.ServerSettings + { + /// + /// The row Id + /// + public long Id { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/SqlServerDatabaseContext.cs b/src/Tgstation.Server.Host/Models/SqlServerDatabaseContext.cs new file mode 100644 index 0000000000..240f8ba1e7 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/SqlServerDatabaseContext.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Models +{ + /// + /// for Sqlserver + /// + sealed class SqlServerDatabaseContext : DatabaseContext + { + /// + /// Construct a + /// + /// The for the + /// The of for the + /// The for the + /// The for the + public SqlServerDatabaseContext(DbContextOptions dbContextOptions, IOptions databaseConfiguration, ILoggerFactory loggerFactory, IDatabaseSeeder databaseSeeder) : base(dbContextOptions, databaseConfiguration, loggerFactory, databaseSeeder) + { } + + /// + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + base.OnConfiguring(options); + options.UseSqlServer(ConnectionString); + } + } +} diff --git a/src/Tgstation.Server.Host/Models/SqliteDatabaseContext.cs b/src/Tgstation.Server.Host/Models/SqliteDatabaseContext.cs new file mode 100644 index 0000000000..0d6c1c94e3 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/SqliteDatabaseContext.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Models +{ + /// + /// for Sqlite + /// + sealed class SqliteDatabaseContext : DatabaseContext + { + /// + /// Construct a + /// + /// The for the + /// The of for the + /// The for the + /// The for the + public SqliteDatabaseContext(DbContextOptions dbContextOptions, IOptions databaseConfiguration, ILoggerFactory loggerFactory, IDatabaseSeeder databaseSeeder) : base(dbContextOptions, databaseConfiguration, loggerFactory, databaseSeeder) + { } + + /// + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + base.OnConfiguring(options); + options.UseSqlite(ConnectionString); + } + } +} diff --git a/src/Tgstation.Server.Host/Models/TestMerge.cs b/src/Tgstation.Server.Host/Models/TestMerge.cs new file mode 100644 index 0000000000..e72fc66e3c --- /dev/null +++ b/src/Tgstation.Server.Host/Models/TestMerge.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class TestMerge : Api.Models.Internal.TestMerge + { + /// + /// See + /// + [Required] + public User MergedBy { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Models/User.cs b/src/Tgstation.Server.Host/Models/User.cs new file mode 100644 index 0000000000..dec14852f9 --- /dev/null +++ b/src/Tgstation.Server.Host/Models/User.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Models +{ + /// + public sealed class User : Api.Models.Internal.User + { + /// + /// The hash of the user's password + /// + public string PasswordHash { get; set; } + + /// + /// See + /// + public User CreatedBy { get; set; } + + /// + /// s created by this + /// + public List CreatedUsers { get; set; } + + /// + /// The s for the + /// + public List InstanceUsers { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs new file mode 100644 index 0000000000..dab8b2e60a --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -0,0 +1,61 @@ +using System; +using System.Linq; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Manages s for a scope + /// + sealed class AuthenticationContext : IAuthenticationContext + { + /// + public User User { get; } + + /// + public InstanceUser InstanceUser { get; } + + /// + public ISystemIdentity SystemIdentity { get; } + + /// + /// Construct a + /// + /// The value of + /// The value of + /// The value of + public AuthenticationContext(ISystemIdentity systemIdentity, User user, InstanceUser instanceUser) + { + User = user ?? throw new ArgumentNullException(nameof(user)); + if (systemIdentity == null && User.SystemIdentifier != null) + throw new ArgumentNullException(nameof(systemIdentity)); + InstanceUser = instanceUser; + SystemIdentity = systemIdentity; + } + + /// + public void Dispose() => SystemIdentity.Dispose(); + + /// + public IAuthenticationContext Clone() => new AuthenticationContext(SystemIdentity.Clone(), User, InstanceUser); + + /// + public int GetRight(RightsType rightsType) + { + var isInstance = RightsHelper.IsInstanceRight(rightsType); + + if (isInstance && InstanceUser == null) + return 0; + var rightsEnum = RightsHelper.RightToType(rightsType); + // use the api versions because they're the ones that contain the actual properties + var typeToCheck = isInstance ? typeof(InstanceUser) : typeof(User); + + var prop = typeToCheck.GetProperties().Where(x => x.PropertyType == rightsEnum).First(); + + var right = prop.GetMethod.Invoke(isInstance ? (object)InstanceUser : User, Array.Empty()); + + return (int)right; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs new file mode 100644 index 0000000000..7c7e3465c7 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class AuthenticationContextFactory : IAuthenticationContextFactory + { + /// + public IAuthenticationContext CurrentAuthenticationContext { get; private set; } + + /// + /// The for the + /// + readonly ISystemIdentityFactory systemIdentityFactory; + + /// + /// The for the + /// + readonly IDatabaseContext databaseContext; + + /// + /// Construct an + /// + /// The value of + /// The value of + public AuthenticationContextFactory(ISystemIdentityFactory systemIdentityFactory, IDatabaseContext databaseContext) + { + this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); + this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + } + + /// + public async Task CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken) + { + if (CurrentAuthenticationContext != null) + throw new InvalidOperationException("Authentication context has already been loaded"); + + var userQuery = databaseContext.Users.Where(x => x.Id == userId); + + if (instanceId.HasValue) + userQuery = userQuery.Include(x => x.InstanceUsers.Where(y => y.Id == instanceId)); + + var user = await userQuery.FirstAsync(cancellationToken).ConfigureAwait(false); + + InstanceUser instanceUser = null; + if (instanceId.HasValue) + instanceUser = user.InstanceUsers.First(); + + CurrentAuthenticationContext = new AuthenticationContext(user.SystemIdentifier != null ? systemIdentityFactory.CreateSystemIdentity(user) : null, user, instanceUser); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/CryptographySuite.cs b/src/Tgstation.Server.Host/Security/CryptographySuite.cs new file mode 100644 index 0000000000..a3c5553030 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/CryptographySuite.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Identity; +using System; +using System.Security.Cryptography; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class CryptographySuite : ICryptographySuite + { + /// + /// Generates a secure set of s + /// + /// A secure set of s + public static byte[] GetSecureBytes(int amount) + { + using (var rng = new RNGCryptoServiceProvider()) + { + var byt = new byte[amount]; + rng.GetBytes(byt); + return byt; + } + } + + /// + /// The for the + /// + readonly IPasswordHasher passwordHasher; + + /// + /// Construct a + /// + /// The value of + public CryptographySuite(IPasswordHasher passwordHasher) => this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher)); + + /// + public void SetUserPassword(User user, string newPassword) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + if (String.IsNullOrEmpty(newPassword)) + throw new ArgumentNullException(nameof(newPassword)); + user.PasswordHash = passwordHasher.HashPassword(user, newPassword); + } + + /// + public bool CheckUserPassword(User user, string password) + { + switch(passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password)) + { + case PasswordVerificationResult.Failed: + return false; + case PasswordVerificationResult.SuccessRehashNeeded: + user.PasswordHash = passwordHasher.HashPassword(user, password); + break; + } + return true; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs new file mode 100644 index 0000000000..2b1ee41a25 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -0,0 +1,40 @@ +using System; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Represents the currently authenticated + /// + public interface IAuthenticationContext : IDisposable + { + /// + /// The authenticated user + /// + User User { get; } + + /// + /// The of if applicable + /// + InstanceUser InstanceUser { get; } + + /// + /// Get the value of a given + /// + /// The of the right to get + /// The value of . Note that if is all based rights will return 0 + int GetRight(RightsType rightsType); + + /// + /// The of if applicable + /// + ISystemIdentity SystemIdentity { get; } + + /// + /// Creates a copy of the + /// + /// A new + IAuthenticationContext Clone(); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs new file mode 100644 index 0000000000..e0f3a225f2 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Security +{ + /// + /// For creating and accessing authentication contexts + /// + public interface IAuthenticationContextFactory + { + /// + /// The the created + /// + IAuthenticationContext CurrentAuthenticationContext { get; } + + /// + /// Create an to populate + /// + /// The of the + /// The of the operation + /// The for the operation + /// A representing the running operation + Task CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/ICryptographySuite.cs b/src/Tgstation.Server.Host/Security/ICryptographySuite.cs new file mode 100644 index 0000000000..b19f805871 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ICryptographySuite.cs @@ -0,0 +1,25 @@ +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Contains various cryptographic functions + /// + public interface ICryptographySuite + { + /// + /// Sets a for a given + /// + /// The whos is to be set + /// The new password for the + void SetUserPassword(User user, string newPassword); + + /// + /// Checks a given matches a given 's . This may result in being modified and this should be persisted + /// + /// The to check + /// The password to check + /// if matches the hash, otherwise + bool CheckUserPassword(User user, string password); + } +} diff --git a/src/Tgstation.Server.Host/Security/ISystemIdentity.cs b/src/Tgstation.Server.Host/Security/ISystemIdentity.cs new file mode 100644 index 0000000000..ff2dc3482c --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ISystemIdentity.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Represents a user on the current + /// + public interface ISystemIdentity : IDisposable + { + /// + /// A unique identifier for the user + /// + string Uid { get; } + + /// + /// The user's name + /// + string Username { get; } + + /// + /// Groups the user is in + /// + IEnumerable Groups { get; } + + /// + /// Clone the creating another copy that must have called on it + /// + /// A new mirroring the current one + ISystemIdentity Clone(); + + /// + /// Runs a given in the context of the + /// + /// The to perform, should be simple and not use any s or threading + /// The for the operation + /// A representing the running operation + Task RunImpersonated(Action action, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Security/ISystemIdentityFactory.cs b/src/Tgstation.Server.Host/Security/ISystemIdentityFactory.cs new file mode 100644 index 0000000000..86f4f9b7e2 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ISystemIdentityFactory.cs @@ -0,0 +1,25 @@ +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Factory for s + /// + public interface ISystemIdentityFactory + { + /// + /// Create a for a given + /// + /// The user to create a for + /// A new or if the has no + ISystemIdentity CreateSystemIdentity(User user); + + /// + /// Create a for a given username and password + /// + /// The username of the user + /// The password of the user + /// A new + ISystemIdentity CreateSystemIdentity(string username, string password); + } +} diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs new file mode 100644 index 0000000000..77f8a1518c --- /dev/null +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// For creating s + /// + public interface ITokenFactory + { + /// + /// Create a for a given + /// + /// The to create the token for. Must have the field available + /// A new + Token CreateToken(Models.User user); + } +} diff --git a/src/Tgstation.Server.Host/Security/SystemIdentityFactory.cs b/src/Tgstation.Server.Host/Security/SystemIdentityFactory.cs new file mode 100644 index 0000000000..f9e9c15a87 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/SystemIdentityFactory.cs @@ -0,0 +1,21 @@ +using System; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class SystemIdentityFactory : ISystemIdentityFactory + { + /// + public ISystemIdentity CreateSystemIdentity(User user) + { + throw new NotImplementedException(); + } + + /// + public ISystemIdentity CreateSystemIdentity(string username, string password) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs new file mode 100644 index 0000000000..e0e4c9af7b --- /dev/null +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -0,0 +1,40 @@ +using Microsoft.IdentityModel.Tokens; +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Reflection; +using System.Security.Claims; +using System.Text; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + sealed class TokenFactory : ITokenFactory + { + public static readonly string TokenAudience = typeof(Token).Assembly.GetName().Name; + public static readonly string TokenIssuer = Assembly.GetExecutingAssembly().GetName().Name; + public static readonly byte[] TokenSigningKey = CryptographySuite.GetSecureBytes(256); + + /// + public Token CreateToken(Models.User user) + { + if (user == null) + throw new ArgumentNullException(nameof(user)); + + var claims = new Claim[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString(CultureInfo.InvariantCulture)), + new Claim(JwtRegisteredClaimNames.Exp, $"{DateTimeOffset.Now.AddHours(1).ToUnixTimeSeconds()}"), + new Claim(JwtRegisteredClaimNames.Nbf, $"{DateTimeOffset.Now.ToUnixTimeSeconds()}"), + new Claim(JwtRegisteredClaimNames.Iss, TokenIssuer), + new Claim(JwtRegisteredClaimNames.Aud, TokenAudience) + }; + + var key = new SymmetricSecurityKey(TokenSigningKey); + + var token = new JwtSecurityToken(new JwtHeader(new SigningCredentials(key, SecurityAlgorithms.HmacSha256)), new JwtPayload(claims)); + return new Token { Bearer = new JwtSecurityTokenHandler().WriteToken(token) }; + } + } +} diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index 50406a988d..506424c49c 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -1,19 +1,60 @@ -using System.Threading; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Startup; namespace Tgstation.Server.Host { /// - sealed class Server : IServer + sealed class Server : IServer, IServerUpdateConsumer { /// - public string UpdatePath => null; + public string UpdatePath { get; private set; } + + /// + /// The for the + /// + readonly IWebHostBuilder webHostBuilder; + + /// + /// The for the + /// + CancellationTokenSource cancellationTokenSource; + + /// + /// Construct a + /// + /// The value of + public Server(IWebHostBuilder webHostBuilder) => this.webHostBuilder = webHostBuilder ?? throw new ArgumentNullException(nameof(webHostBuilder)); /// - public void Dispose() { } + [ExcludeFromCodeCoverage] + public async Task RunAsync(CancellationToken cancellationToken) + { + cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using (cancellationTokenSource) + using (var webHost = webHostBuilder + .UseStartup() + .ConfigureServices((serviceCollection) => serviceCollection.AddSingleton(this)) + .Build() + ) + await webHost.RunAsync(cancellationToken).ConfigureAwait(false); + } /// - public Task RunAsync(string[] args, CancellationToken cancellationToken) => Task.CompletedTask; + public void ApplyUpdate(string updatePath) + { + lock (this) + { + if (updatePath != null) + throw new InvalidOperationException("ApplyUpdate has already been called!"); + UpdatePath = updatePath; + } + cancellationTokenSource.Cancel(); + } } } diff --git a/src/Tgstation.Server.Host/ServerFactory.cs b/src/Tgstation.Server.Host/ServerFactory.cs index e0f9291516..810ef924e2 100644 --- a/src/Tgstation.Server.Host/ServerFactory.cs +++ b/src/Tgstation.Server.Host/ServerFactory.cs @@ -1,4 +1,5 @@ -using Tgstation.Server.Host.Startup; +using Microsoft.AspNetCore; +using Tgstation.Server.Host.Startup; namespace Tgstation.Server.Host { @@ -6,6 +7,6 @@ namespace Tgstation.Server.Host public sealed class ServerFactory : IServerFactory { /// - public IServer CreateServer() => new Server(); + public IServer CreateServer(string[] args) => new Server(WebHost.CreateDefaultBuilder(args)); } } diff --git a/src/Tgstation.Server.Host/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/TgsAuthorizeAttribute.cs new file mode 100644 index 0000000000..2281587fae --- /dev/null +++ b/src/Tgstation.Server.Host/TgsAuthorizeAttribute.cs @@ -0,0 +1,72 @@ +using System; +using Microsoft.AspNetCore.Authorization; +using Tgstation.Server.Api.Rights; + +namespace Tgstation.Server.Host +{ + /// + /// Helper for using the with the system + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + sealed class TgsAuthorizeAttribute : AuthorizeAttribute + { + /// + /// Construct a + /// + public TgsAuthorizeAttribute() { } + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(AdministrationRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(InstanceManagerRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(RepositoryRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(ByondRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(DreamMakerRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(DreamDaemonRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(ChatSettingsRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(ConfigurationRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + + /// + /// Construct a for + /// + /// The rights required + public TgsAuthorizeAttribute(InstanceUserRights requiredRights) => Roles = RightsHelper.RoleName(requiredRights); + } +} diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index d204eda12b..2370aabf52 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -10,17 +10,51 @@ true bin\Release\netstandard2.0\Tgstation.Server.Host.xml + 1701;1702;1705;CA2227 latest + 1701;1702;1705;CA2227 - - + + + + + PreserveNewest + + + PreserveNewest + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tgstation.Server.Host/appsettings.Docker.json b/src/Tgstation.Server.Host/appsettings.Docker.json new file mode 100644 index 0000000000..906e461ee4 --- /dev/null +++ b/src/Tgstation.Server.Host/appsettings.Docker.json @@ -0,0 +1,6 @@ +{ + "Database": { + "DatabaseType": "SqlServer", + "ConnectionString": "" + } +} diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json new file mode 100644 index 0000000000..bfcbeffa32 --- /dev/null +++ b/src/Tgstation.Server.Host/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "IncludeScopes": false, + "Debug": { + "LogLevel": { + "Default": "Debug" + } + }, + "Console": { + "LogLevel": { + "Default": "Trace" + } + }, + "EntityFramework": { + "LogLevel": { + "Default": "Warning" + } + } + }, + "Database": { + "DatabaseType": "Sqlite", + "ConnectionString": "Fake" + } +} diff --git a/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj b/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj index fdbb9cfe04..ccb09b4c3c 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj +++ b/tests/Tgstation.Server.Host.Service.Tests/Tgstation.Server.Host.Service.Tests.csproj @@ -75,7 +75,7 @@ Tgstation.Server.Host.Service - {5D2D682C-6BF0-439C-850B-6AB945BBEAEA} + {5d2d682c-6bf0-439c-850b-6ab945bbeaea} Tgstation.Server.Host.Watchdog diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs new file mode 100644 index 0000000000..980faf020a --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -0,0 +1,53 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security.Tests +{ + /// + /// Tests for + /// + [TestClass] + public sealed class TestAuthenticationContext + { + [TestMethod] + public void TestConstruction() + { + Assert.ThrowsException(() => new AuthenticationContext(null, null, null)); + var mockSystemIdentity = new Mock(); + + var user = new User(); + + var authContext = new AuthenticationContext(null, user, null); + Assert.ThrowsException(() => new AuthenticationContext(mockSystemIdentity.Object, null, null)); + + var instanceUser = new InstanceUser(); + + Assert.ThrowsException(() => new AuthenticationContext(null, null, instanceUser)); + Assert.ThrowsException(() => new AuthenticationContext(mockSystemIdentity.Object, null, instanceUser)); + authContext = new AuthenticationContext(mockSystemIdentity.Object, user, null); + authContext = new AuthenticationContext(null, user, instanceUser); + authContext = new AuthenticationContext(mockSystemIdentity.Object, user, instanceUser); + user.SystemIdentifier = "root"; + Assert.ThrowsException(() => new AuthenticationContext(null, user, null)); + } + + + [TestMethod] + public void TestGetRightsGeneric() + { + var user = new User(); + var instanceUser = new InstanceUser(); + var authContext = new AuthenticationContext(null, user, instanceUser); + + user.AdministrationRights = AdministrationRights.EditUsers; + instanceUser.ByondRights = ByondRights.ChangeVersion | ByondRights.ReadInstalled; + Assert.AreEqual((int)user.AdministrationRights, authContext.GetRight(RightsType.Administration)); + Assert.AreEqual((int)user.InstanceManagerRights, authContext.GetRight(RightsType.InstanceManager)); + Assert.AreEqual((int)instanceUser.ByondRights, authContext.GetRight(RightsType.Byond)); + Assert.AreEqual((int)instanceUser.RepositoryRights, authContext.GetRight(RightsType.Repository)); + } + } +} diff --git a/tests/Tgstation.Server.Host.Tests/TestServer.cs b/tests/Tgstation.Server.Host.Tests/TestServer.cs deleted file mode 100644 index 5e20bd627e..0000000000 --- a/tests/Tgstation.Server.Host.Tests/TestServer.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Tgstation.Server.Host.Tests -{ - [TestClass] - public sealed class TestServer - { - [TestMethod] - public async Task TestRunAndDispose() - { - using (var server = new Server()) - { - await server.RunAsync(null, default).ConfigureAwait(false); - Assert.IsNull(server.UpdatePath); - } - } - } -} diff --git a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj index f637b93ead..6481ec2802 100644 --- a/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj +++ b/tests/Tgstation.Server.Host.Tests/Tgstation.Server.Host.Tests.csproj @@ -9,8 +9,9 @@ - - + + + diff --git a/tests/Tgstation.Server.Host.Watchdog.Tests/TestIsolatedServerFactory.cs b/tests/Tgstation.Server.Host.Watchdog.Tests/TestIsolatedServerFactory.cs index 33187c485b..ab382bff97 100644 --- a/tests/Tgstation.Server.Host.Watchdog.Tests/TestIsolatedServerFactory.cs +++ b/tests/Tgstation.Server.Host.Watchdog.Tests/TestIsolatedServerFactory.cs @@ -20,7 +20,7 @@ namespace Tgstation.Server.Host.Watchdog.Tests public void TestLoading() { var isf = new IsolatedServerFactory(typeof(ServerFactory).Assembly.Location); - Assert.IsNotNull(isf.CreateServer()); + Assert.IsNotNull(isf.CreateServer(Array.Empty())); } } } diff --git a/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs b/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs index 00b492d110..5d74371f08 100644 --- a/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs +++ b/tests/Tgstation.Server.Host.Watchdog.Tests/TestWatchdog.cs @@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Watchdog.Tests { readonly IServer server; public MockServerFactory(IServer server) => this.server = server; - public IServer CreateServer() => server; + public IServer CreateServer(string[] args) => server; } [TestMethod] @@ -41,7 +41,7 @@ namespace Tgstation.Server.Host.Watchdog.Tests using (var cts = new CancellationTokenSource()) { - mockServer.Setup(x => x.RunAsync(It.IsNotNull(), cts.Token)).Returns(Task.CompletedTask).Verifiable(); + mockServer.Setup(x => x.RunAsync(cts.Token)).Returns(Task.CompletedTask).Verifiable(); await wd.RunAsync(Array.Empty(), cts.Token).ConfigureAwait(false); mockServer.VerifyAll(); } @@ -62,7 +62,7 @@ namespace Tgstation.Server.Host.Watchdog.Tests using (var cts = new CancellationTokenSource()) { int count = 0; - mockServer.Setup(x => x.RunAsync(It.IsNotNull(), cts.Token)).Callback(() => + mockServer.Setup(x => x.RunAsync(cts.Token)).Callback(() => { if (++count > 1) cts.Cancel(); diff --git a/tgstation-server.sln b/tgstation-server.sln index 58f4e6fea3..91b6751581 100644 --- a/tgstation-server.sln +++ b/tgstation-server.sln @@ -60,79 +60,115 @@ EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Docker|Any CPU = Docker|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A09C947F-4B9B-4AEE-AB50-47055EA05F18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A09C947F-4B9B-4AEE-AB50-47055EA05F18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A09C947F-4B9B-4AEE-AB50-47055EA05F18}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {A09C947F-4B9B-4AEE-AB50-47055EA05F18}.Docker|Any CPU.Build.0 = Release|Any CPU {A09C947F-4B9B-4AEE-AB50-47055EA05F18}.Release|Any CPU.ActiveCfg = Release|Any CPU {A09C947F-4B9B-4AEE-AB50-47055EA05F18}.Release|Any CPU.Build.0 = Release|Any CPU {D36B99C4-E771-42D6-A95F-1102B3E236DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D36B99C4-E771-42D6-A95F-1102B3E236DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D36B99C4-E771-42D6-A95F-1102B3E236DF}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {D36B99C4-E771-42D6-A95F-1102B3E236DF}.Docker|Any CPU.Build.0 = Release|Any CPU {D36B99C4-E771-42D6-A95F-1102B3E236DF}.Release|Any CPU.ActiveCfg = Release|Any CPU {D36B99C4-E771-42D6-A95F-1102B3E236DF}.Release|Any CPU.Build.0 = Release|Any CPU {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A}.Docker|Any CPU.Build.0 = Release|Any CPU {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A}.Release|Any CPU.ActiveCfg = Release|Any CPU {2B69AD6D-2B5A-4023-8EAD-0BD1B18E028A}.Release|Any CPU.Build.0 = Release|Any CPU {8B4A208D-A48A-4A5D-8B94-E2661138865D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8B4A208D-A48A-4A5D-8B94-E2661138865D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B4A208D-A48A-4A5D-8B94-E2661138865D}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {8B4A208D-A48A-4A5D-8B94-E2661138865D}.Docker|Any CPU.Build.0 = Release|Any CPU {8B4A208D-A48A-4A5D-8B94-E2661138865D}.Release|Any CPU.ActiveCfg = Release|Any CPU {8B4A208D-A48A-4A5D-8B94-E2661138865D}.Release|Any CPU.Build.0 = Release|Any CPU {29927416-3B78-49A7-A560-5CCAA638B6B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {29927416-3B78-49A7-A560-5CCAA638B6B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29927416-3B78-49A7-A560-5CCAA638B6B4}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {29927416-3B78-49A7-A560-5CCAA638B6B4}.Docker|Any CPU.Build.0 = Release|Any CPU {29927416-3B78-49A7-A560-5CCAA638B6B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {29927416-3B78-49A7-A560-5CCAA638B6B4}.Release|Any CPU.Build.0 = Release|Any CPU {657C3624-3534-4A0C-B4E7-196FC830F547}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {657C3624-3534-4A0C-B4E7-196FC830F547}.Debug|Any CPU.Build.0 = Debug|Any CPU + {657C3624-3534-4A0C-B4E7-196FC830F547}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {657C3624-3534-4A0C-B4E7-196FC830F547}.Docker|Any CPU.Build.0 = Release|Any CPU {657C3624-3534-4A0C-B4E7-196FC830F547}.Release|Any CPU.ActiveCfg = Release|Any CPU {657C3624-3534-4A0C-B4E7-196FC830F547}.Release|Any CPU.Build.0 = Release|Any CPU {D38BE569-04B7-4C64-BDB5-683767B26FEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D38BE569-04B7-4C64-BDB5-683767B26FEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D38BE569-04B7-4C64-BDB5-683767B26FEB}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {D38BE569-04B7-4C64-BDB5-683767B26FEB}.Docker|Any CPU.Build.0 = Release|Any CPU {D38BE569-04B7-4C64-BDB5-683767B26FEB}.Release|Any CPU.ActiveCfg = Release|Any CPU {D38BE569-04B7-4C64-BDB5-683767B26FEB}.Release|Any CPU.Build.0 = Release|Any CPU {E0AC911F-7675-4A91-9499-D8A2E2390AAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E0AC911F-7675-4A91-9499-D8A2E2390AAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0AC911F-7675-4A91-9499-D8A2E2390AAD}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {E0AC911F-7675-4A91-9499-D8A2E2390AAD}.Docker|Any CPU.Build.0 = Release|Any CPU {E0AC911F-7675-4A91-9499-D8A2E2390AAD}.Release|Any CPU.ActiveCfg = Release|Any CPU {E0AC911F-7675-4A91-9499-D8A2E2390AAD}.Release|Any CPU.Build.0 = Release|Any CPU {93CA946D-50DE-4AD4-BADA-D0353F0AFFCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {93CA946D-50DE-4AD4-BADA-D0353F0AFFCC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93CA946D-50DE-4AD4-BADA-D0353F0AFFCC}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {93CA946D-50DE-4AD4-BADA-D0353F0AFFCC}.Docker|Any CPU.Build.0 = Release|Any CPU {93CA946D-50DE-4AD4-BADA-D0353F0AFFCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {93CA946D-50DE-4AD4-BADA-D0353F0AFFCC}.Release|Any CPU.Build.0 = Release|Any CPU {BE0E8F49-334F-49D7-A04D-D82E9AB7AA36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BE0E8F49-334F-49D7-A04D-D82E9AB7AA36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE0E8F49-334F-49D7-A04D-D82E9AB7AA36}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {BE0E8F49-334F-49D7-A04D-D82E9AB7AA36}.Docker|Any CPU.Build.0 = Release|Any CPU {BE0E8F49-334F-49D7-A04D-D82E9AB7AA36}.Release|Any CPU.ActiveCfg = Release|Any CPU {BE0E8F49-334F-49D7-A04D-D82E9AB7AA36}.Release|Any CPU.Build.0 = Release|Any CPU {E5301AF1-4F74-4982-BF24-95F23CC5D5B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E5301AF1-4F74-4982-BF24-95F23CC5D5B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5301AF1-4F74-4982-BF24-95F23CC5D5B2}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {E5301AF1-4F74-4982-BF24-95F23CC5D5B2}.Docker|Any CPU.Build.0 = Release|Any CPU {E5301AF1-4F74-4982-BF24-95F23CC5D5B2}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5301AF1-4F74-4982-BF24-95F23CC5D5B2}.Release|Any CPU.Build.0 = Release|Any CPU {90D400B4-FCB0-447E-8FB2-1CF58EE7E55D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {90D400B4-FCB0-447E-8FB2-1CF58EE7E55D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90D400B4-FCB0-447E-8FB2-1CF58EE7E55D}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {90D400B4-FCB0-447E-8FB2-1CF58EE7E55D}.Docker|Any CPU.Build.0 = Release|Any CPU {90D400B4-FCB0-447E-8FB2-1CF58EE7E55D}.Release|Any CPU.ActiveCfg = Release|Any CPU {90D400B4-FCB0-447E-8FB2-1CF58EE7E55D}.Release|Any CPU.Build.0 = Release|Any CPU {A3362FF6-550F-480F-859E-8EC1EB6EAB31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A3362FF6-550F-480F-859E-8EC1EB6EAB31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3362FF6-550F-480F-859E-8EC1EB6EAB31}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {A3362FF6-550F-480F-859E-8EC1EB6EAB31}.Docker|Any CPU.Build.0 = Release|Any CPU {A3362FF6-550F-480F-859E-8EC1EB6EAB31}.Release|Any CPU.ActiveCfg = Release|Any CPU {A3362FF6-550F-480F-859E-8EC1EB6EAB31}.Release|Any CPU.Build.0 = Release|Any CPU {DB2A5EE6-AF5C-410C-A36C-EA378FE9CBFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DB2A5EE6-AF5C-410C-A36C-EA378FE9CBFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB2A5EE6-AF5C-410C-A36C-EA378FE9CBFE}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {DB2A5EE6-AF5C-410C-A36C-EA378FE9CBFE}.Docker|Any CPU.Build.0 = Release|Any CPU {DB2A5EE6-AF5C-410C-A36C-EA378FE9CBFE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DB2A5EE6-AF5C-410C-A36C-EA378FE9CBFE}.Release|Any CPU.Build.0 = Release|Any CPU {5D2D682C-6BF0-439C-850B-6AB945BBEAEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5D2D682C-6BF0-439C-850B-6AB945BBEAEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D2D682C-6BF0-439C-850B-6AB945BBEAEA}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {5D2D682C-6BF0-439C-850B-6AB945BBEAEA}.Docker|Any CPU.Build.0 = Release|Any CPU {5D2D682C-6BF0-439C-850B-6AB945BBEAEA}.Release|Any CPU.ActiveCfg = Release|Any CPU {5D2D682C-6BF0-439C-850B-6AB945BBEAEA}.Release|Any CPU.Build.0 = Release|Any CPU {7500F776-4384-4B5F-A8D8-22461CAD108B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7500F776-4384-4B5F-A8D8-22461CAD108B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7500F776-4384-4B5F-A8D8-22461CAD108B}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {7500F776-4384-4B5F-A8D8-22461CAD108B}.Docker|Any CPU.Build.0 = Release|Any CPU {7500F776-4384-4B5F-A8D8-22461CAD108B}.Release|Any CPU.ActiveCfg = Release|Any CPU {7500F776-4384-4B5F-A8D8-22461CAD108B}.Release|Any CPU.Build.0 = Release|Any CPU {7D067E1B-06A1-49C8-A8E6-7ACBA28A5A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D067E1B-06A1-49C8-A8E6-7ACBA28A5A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D067E1B-06A1-49C8-A8E6-7ACBA28A5A41}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {7D067E1B-06A1-49C8-A8E6-7ACBA28A5A41}.Docker|Any CPU.Build.0 = Release|Any CPU {7D067E1B-06A1-49C8-A8E6-7ACBA28A5A41}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D067E1B-06A1-49C8-A8E6-7ACBA28A5A41}.Release|Any CPU.Build.0 = Release|Any CPU {AA80A190-52E2-4BE3-BFEB-1F148D9E9007}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AA80A190-52E2-4BE3-BFEB-1F148D9E9007}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA80A190-52E2-4BE3-BFEB-1F148D9E9007}.Docker|Any CPU.ActiveCfg = Release|Any CPU + {AA80A190-52E2-4BE3-BFEB-1F148D9E9007}.Docker|Any CPU.Build.0 = Release|Any CPU {AA80A190-52E2-4BE3-BFEB-1F148D9E9007}.Release|Any CPU.ActiveCfg = Release|Any CPU {AA80A190-52E2-4BE3-BFEB-1F148D9E9007}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection