diff --git a/build/ControlPanelVersion.props b/build/ControlPanelVersion.props index c2a4c37602..02d75e7142 100644 --- a/build/ControlPanelVersion.props +++ b/build/ControlPanelVersion.props @@ -1,6 +1,6 @@ - 4.25.5 + 4.26.2 diff --git a/build/TestCommon.props b/build/TestCommon.props index 5ee30f1147..5562510f72 100644 --- a/build/TestCommon.props +++ b/build/TestCommon.props @@ -15,8 +15,8 @@ - - + + diff --git a/build/Version.props b/build/Version.props index 5059cb754d..349d3241c5 100644 --- a/build/Version.props +++ b/build/Version.props @@ -7,8 +7,8 @@ 5.0.0 10.0.0 7.0.0 - 12.0.0 - 14.0.0 + 13.0.0 + 15.0.0 7.0.0 5.7.0 1.4.0 diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index 0c5a955227..034747c138 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -1,4 +1,4 @@ - + @@ -88,7 +88,7 @@ - + @@ -971,7 +971,7 @@ - + diff --git a/src/DMAPI/tgs/core/datum.dm b/src/DMAPI/tgs/core/datum.dm index de420a2a32..07ce3b6845 100644 --- a/src/DMAPI/tgs/core/datum.dm +++ b/src/DMAPI/tgs/core/datum.dm @@ -12,8 +12,13 @@ TGS_DEFINE_AND_SET_GLOBAL(tgs, null) src.version = version /datum/tgs_api/proc/TerminateWorld() - del(world) - sleep(1) // https://www.byond.com/forum/post/2894866 + while(TRUE) + TGS_DEBUG_LOG("About to terminate world. Tick: [world.time], sleep_offline: [world.sleep_offline]") + world.sleep_offline = FALSE // https://www.byond.com/forum/post/2894866 + del(world) + world.sleep_offline = FALSE // just in case, this is BYOND after all... + sleep(1) + TGS_DEBUG_LOG("BYOND DIDN'T TERMINATE THE WORLD!!! TICK IS: [world.time], sleep_offline: [world.sleep_offline]") /datum/tgs_api/latest parent_type = /datum/tgs_api/v5 diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 7fe6edafc3..01e61cb373 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Properties; using Tgstation.Server.Common.Extensions; @@ -57,6 +58,11 @@ namespace Tgstation.Server.Api /// public const string ApplicationJsonMime = "application/json"; + /// + /// Added to in netstandard2.1. Can't use because of lack of .NET Framework support. + /// + const string TextEventStreamMime = "text/event-stream"; + /// /// Get the version of the the caller is using. /// @@ -93,9 +99,9 @@ namespace Tgstation.Server.Api public Version ApiVersion { get; } /// - /// The client's JWT. + /// The client's . /// - public string? Token { get; } + public TokenResponse? Token { get; } /// /// The client's username. @@ -107,13 +113,18 @@ namespace Tgstation.Server.Api /// public string? Password { get; } + /// + /// The OAuth code in use. + /// + public string? OAuthCode { get; } + /// /// The the is for, if any. /// public OAuthProvider? OAuthProvider { get; } /// - /// If the header uses password or TGS JWT authentication. + /// If the header uses OAuth or TGS JWT authentication. /// public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue; @@ -129,16 +140,31 @@ namespace Tgstation.Server.Api /// /// The value of . /// The value of . - /// The value of . - public ApiHeaders(ProductHeaderValue userAgent, string token, OAuthProvider? oauthProvider = null) + public ApiHeaders(ProductHeaderValue userAgent, TokenResponse token) : this(userAgent, token, null, null) { if (userAgent == null) throw new ArgumentNullException(nameof(userAgent)); if (token == null) throw new ArgumentNullException(nameof(token)); + if (token.Bearer == null) + throw new InvalidOperationException("token.Bearer must be set!"); + } - OAuthProvider = oauthProvider; + /// + /// Initializes a new instance of the class. Used for token authentication. + /// + /// The value of . + /// The value of . + /// The value of . + public ApiHeaders(ProductHeaderValue userAgent, string oAuthCode, OAuthProvider oAuthProvider) + : this(userAgent, null, null, null) + { + if (userAgent == null) + throw new ArgumentNullException(nameof(userAgent)); + + OAuthCode = oAuthCode ?? throw new ArgumentNullException(nameof(oAuthCode)); + OAuthProvider = oAuthProvider; } /// @@ -163,48 +189,57 @@ namespace Tgstation.Server.Api /// /// The containing the serialized . /// If a missing should be ignored. + /// If is a valid accept. /// Thrown if the constitue invalid . #pragma warning disable CA1502 // TODO: Decomplexify - public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false) + public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth, bool allowEventStreamAccept) { if (requestHeaders == null) throw new ArgumentNullException(nameof(requestHeaders)); - var badHeaders = HeaderTypes.None; + var badHeaders = HeaderErrorTypes.None; var errorBuilder = new StringBuilder(); - - void AddError(HeaderTypes headerType, string message) + var multipleErrors = false; + void AddError(HeaderErrorTypes headerType, string message) { - if (badHeaders != HeaderTypes.None) + if (badHeaders != HeaderErrorTypes.None) + { + multipleErrors = true; errorBuilder.AppendLine(); + } + badHeaders |= headerType; errorBuilder.Append(message); } var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJsonMime); - if (!requestHeaders.Accept.Any(x => jsonAccept.IsSubsetOf(x))) - AddError(HeaderTypes.Accept, $"Client does not accept {ApplicationJsonMime}!"); + var eventStreamAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(TextEventStreamMime); + if (!requestHeaders.Accept.Any(jsonAccept.IsSubsetOf)) + if (!allowEventStreamAccept) + AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime}!"); + else if (!requestHeaders.Accept.Any(eventStreamAccept.IsSubsetOf)) + AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime} or {TextEventStreamMime}!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgentValues) || userAgentValues.Count == 0) - AddError(HeaderTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!"); + AddError(HeaderErrorTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!"); else { RawUserAgent = userAgentValues.First(); if (String.IsNullOrWhiteSpace(RawUserAgent)) - AddError(HeaderTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!"); + AddError(HeaderErrorTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!"); } // make sure the api header matches ours Version? apiVersion = null; if (!requestHeaders.Headers.TryGetValue(ApiVersionHeader, out var apiUserAgentHeaderValues) || !ProductInfoHeaderValue.TryParse(apiUserAgentHeaderValues.FirstOrDefault(), out var apiUserAgent) || apiUserAgent.Product.Name != AssemblyName.Name) - AddError(HeaderTypes.Api, $"Missing {ApiVersionHeader} header!"); + AddError(HeaderErrorTypes.Api, $"Missing {ApiVersionHeader} header!"); else if (!Version.TryParse(apiUserAgent.Product.Version, out apiVersion)) - AddError(HeaderTypes.Api, $"Malformed {ApiVersionHeader} header!"); + AddError(HeaderErrorTypes.Api, $"Malformed {ApiVersionHeader} header!"); if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization)) { if (!ignoreMissingAuth) - AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!"); + AddError(HeaderErrorTypes.AuthorizationMissing, $"Missing {HeaderNames.Authorization} header!"); } else { @@ -212,13 +247,13 @@ namespace Tgstation.Server.Api var splits = new List(auth.Split(' ')); var scheme = splits.First(); if (String.IsNullOrWhiteSpace(scheme)) - AddError(HeaderTypes.Authorization, "Missing authentication scheme!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication scheme!"); else { splits.RemoveAt(0); var parameter = String.Concat(splits); if (String.IsNullOrEmpty(parameter)) - AddError(HeaderTypes.Authorization, "Missing authentication parameter!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication parameter!"); else { if (requestHeaders.Headers.TryGetValue(InstanceIdHeader, out var instanceIdValues)) @@ -237,14 +272,30 @@ namespace Tgstation.Server.Api if (Enum.TryParse(oauthProviderString, out var oauthProvider)) OAuthProvider = oauthProvider; else - AddError(HeaderTypes.OAuthProvider, "Invalid OAuth provider!"); + AddError(HeaderErrorTypes.OAuthProvider, "Invalid OAuth provider!"); } else - AddError(HeaderTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!"); + AddError(HeaderErrorTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!"); - goto case BearerAuthenticationScheme; + OAuthCode = parameter; + break; case BearerAuthenticationScheme: - Token = parameter; + Token = new TokenResponse + { + Bearer = parameter, + }; + + try + { +#pragma warning disable CS0618 // Type or member is obsolete + Token.ExpiresAt = Token.ParseJwt().ValidTo; +#pragma warning restore CS0618 // Type or member is obsolete + } + catch (ArgumentException ex) when (ex is not ArgumentNullException) + { + AddError(HeaderErrorTypes.AuthorizationInvalid, $"Invalid JWT: {ex.Message}"); + } + break; case BasicAuthenticationScheme: string badBasicAuthHeaderMessage = $"Invalid basic {HeaderNames.Authorization} header!"; @@ -256,14 +307,14 @@ namespace Tgstation.Server.Api } catch { - AddError(HeaderTypes.Authorization, badBasicAuthHeaderMessage); + AddError(HeaderErrorTypes.AuthorizationInvalid, badBasicAuthHeaderMessage); break; } var basicAuthSplits = joinedString.Split(ColonSeparator, StringSplitOptions.RemoveEmptyEntries); if (basicAuthSplits.Length < 2) { - AddError(HeaderTypes.Authorization, badBasicAuthHeaderMessage); + AddError(HeaderErrorTypes.AuthorizationInvalid, badBasicAuthHeaderMessage); break; } @@ -271,15 +322,20 @@ namespace Tgstation.Server.Api Password = String.Concat(basicAuthSplits.Skip(1)); break; default: - AddError(HeaderTypes.Authorization, "Invalid authentication scheme!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid authentication scheme!"); break; } } } } - if (badHeaders != HeaderTypes.None) + if (badHeaders != HeaderErrorTypes.None) + { + if (multipleErrors) + errorBuilder.Insert(0, $"Multiple header validation errors occurred:{Environment.NewLine}"); + throw new HeadersException(badHeaders, errorBuilder.ToString()); + } ApiVersion = apiVersion!.Semver(); } @@ -292,7 +348,7 @@ namespace Tgstation.Server.Api /// The value of . /// The value of . /// The value of . - ApiHeaders(ProductHeaderValue userAgent, string? token, string? username, string? password) + ApiHeaders(ProductHeaderValue userAgent, TokenResponse? token, string? username, string? password) { RawUserAgent = userAgent?.ToString(); Token = token; @@ -322,10 +378,10 @@ namespace Tgstation.Server.Api headers.Clear(); headers.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJsonMime)); headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent)); - headers.Add(ApiVersionHeader, new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString()); + headers.Add(ApiVersionHeader, CreateApiVersionHeader()); if (OAuthProvider.HasValue) { - headers.Authorization = new AuthenticationHeaderValue(OAuthAuthenticationScheme, Token); + headers.Authorization = new AuthenticationHeaderValue(OAuthAuthenticationScheme, OAuthCode!); headers.Add(OAuthProviderHeader, OAuthProvider.ToString()); } else if (!IsTokenAuthentication) @@ -333,11 +389,32 @@ namespace Tgstation.Server.Api BasicAuthenticationScheme, Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"))); else - headers.Authorization = new AuthenticationHeaderValue(BearerAuthenticationScheme, Token); + headers.Authorization = new AuthenticationHeaderValue(BearerAuthenticationScheme, Token!.Bearer); instanceId ??= InstanceId; if (instanceId.HasValue) headers.Add(InstanceIdHeader, instanceId.Value.ToString(CultureInfo.InvariantCulture)); } + + /// + /// Adds the necessary for a SignalR hub connection. + /// + /// The headers to write to. + public void SetHubConnectionHeaders(IDictionary headers) + { + if (headers == null) + throw new ArgumentNullException(nameof(headers)); + + headers.Add(HeaderNames.UserAgent, RawUserAgent ?? throw new InvalidOperationException("Missing UserAgent!")); + headers.Add(HeaderNames.Accept, ApplicationJsonMime); + headers.Add(ApiVersionHeader, CreateApiVersionHeader()); + } + + /// + /// Create the ified for of the . + /// + /// A representing the . + string CreateApiVersionHeader() + => new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString(); } } diff --git a/src/Tgstation.Server.Api/HeaderErrorTypes.cs b/src/Tgstation.Server.Api/HeaderErrorTypes.cs new file mode 100644 index 0000000000..414b1e22d3 --- /dev/null +++ b/src/Tgstation.Server.Api/HeaderErrorTypes.cs @@ -0,0 +1,46 @@ +using System; + +namespace Tgstation.Server.Api +{ + /// + /// Types of individual errors. + /// + [Flags] + public enum HeaderErrorTypes + { + /// + /// No header errors. + /// + None = 0, + + /// + /// The header is missing or invalid. + /// + UserAgent = 1 << 0, + + /// + /// The header is missing or invalid. + /// + Accept = 1 << 1, + + /// + /// The header is missing or invalid. + /// + Api = 1 << 2, + + /// + /// The header is invalid. + /// + AuthorizationInvalid = 1 << 3, + + /// + /// The header is missing or invalid. + /// + OAuthProvider = 1 << 4, + + /// + /// The header is missing. + /// + AuthorizationMissing = 1 << 5, + } +} diff --git a/src/Tgstation.Server.Api/HeaderTypes.cs b/src/Tgstation.Server.Api/HeaderTypes.cs deleted file mode 100644 index a5fdca325d..0000000000 --- a/src/Tgstation.Server.Api/HeaderTypes.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; - -namespace Tgstation.Server.Api -{ - /// - /// Types of individual . - /// - [Flags] - public enum HeaderTypes - { - /// - /// No headers. - /// - None = 0, - - /// - /// header. - /// - UserAgent = 1 << 0, - - /// - /// header. - /// - Accept = 1 << 1, - - /// - /// . - /// - Api = 1 << 2, - - /// - /// - /// - Authorization = 1 << 3, - - /// - /// . - /// - OAuthProvider = 1 << 4, - } -} diff --git a/src/Tgstation.Server.Api/HeadersException.cs b/src/Tgstation.Server.Api/HeadersException.cs index 6287ee1c88..352d2ce9d8 100644 --- a/src/Tgstation.Server.Api/HeadersException.cs +++ b/src/Tgstation.Server.Api/HeadersException.cs @@ -8,19 +8,19 @@ namespace Tgstation.Server.Api public sealed class HeadersException : Exception { /// - /// The s that are missing or malformed. + /// The s that are missing or malformed. /// - public HeaderTypes MissingOrMalformedHeaders { get; } + public HeaderErrorTypes ParseErrors { get; } /// /// Initializes a new instance of the class. /// - /// The value of . + /// The value of . /// The error message. - public HeadersException(HeaderTypes missingOrMalformedHeaders, string message) + public HeadersException(HeaderErrorTypes parseErrors, string message) : base(message) { - MissingOrMalformedHeaders = missingOrMalformedHeaders; + ParseErrors = parseErrors; } /// diff --git a/src/Tgstation.Server.Api/Hubs/IJobsHub.cs b/src/Tgstation.Server.Api/Hubs/IJobsHub.cs new file mode 100644 index 0000000000..4c595362cb --- /dev/null +++ b/src/Tgstation.Server.Api/Hubs/IJobsHub.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models.Response; + +namespace Tgstation.Server.Api.Hubs +{ + /// + /// SignalR client methods for receiving s. + /// + public interface IJobsHub + { + /// + /// Push a update to the client. + /// + /// The to push. + /// The for the operation. + /// A representing the running operation. + Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 4dba0e189c..d002d80e5c 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -326,7 +326,7 @@ namespace Tgstation.Server.Api.Models /// /// The DMAPI never validated itself /// - [Description("The server did not validate the DMAPI! This can occur if your world is encountering runtime errors during startup.")] + [Description("DMAPI validation failed! See FAQ at https://github.com/tgstation/tgstation-server/discussions/1695")] DeploymentNeverValidated, /// diff --git a/src/Tgstation.Server.Api/Models/Internal/InstancePermissionSet.cs b/src/Tgstation.Server.Api/Models/Internal/InstancePermissionSet.cs index 863e034d09..da6af69640 100644 --- a/src/Tgstation.Server.Api/Models/Internal/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Api/Models/Internal/InstancePermissionSet.cs @@ -1,6 +1,4 @@ -using System; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; using Newtonsoft.Json; @@ -31,14 +29,6 @@ namespace Tgstation.Server.Api.Models.Internal [Required] public EngineRights? EngineRights { get; set; } - /// - /// The legacy of the . - /// - [Required] - [Obsolete("Use EngineRights instead")] - [NotMapped] - public EngineRights? ByondRights { get; set; } - /// /// The of the . /// diff --git a/src/Tgstation.Server.Api/Models/Internal/Job.cs b/src/Tgstation.Server.Api/Models/Internal/Job.cs index 54661a2eac..8b3b210c60 100644 --- a/src/Tgstation.Server.Api/Models/Internal/Job.cs +++ b/src/Tgstation.Server.Api/Models/Internal/Job.cs @@ -10,9 +10,16 @@ namespace Tgstation.Server.Api.Models.Internal /// public class Job : EntityId { + /// + /// The . + /// + [Required] + public JobCode? JobCode { get; set; } + /// /// English description of the . /// + /// May not match the listed on the . [Required] public string? Description { get; set; } diff --git a/src/Tgstation.Server.Api/Models/JobCode.cs b/src/Tgstation.Server.Api/Models/JobCode.cs new file mode 100644 index 0000000000..e82600b302 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/JobCode.cs @@ -0,0 +1,112 @@ +using System.ComponentModel; + +namespace Tgstation.Server.Api.Models +{ + /// + /// The different types of . + /// + public enum JobCode : byte + { + /// + /// This catch-all code is applied to jobs that were created on a tgstation-server before v5.17.0. + /// + [Description("Legacy job")] + Unknown, + + /// + /// When the instance is being moved. + /// + [Description("Instance move")] + Move, + + /// + /// When the repository is cloning. + /// + [Description("Clone repository")] + RepositoryClone, + + /// + /// When the repository is being manually updated. + /// + [Description("Update repository")] + RepositoryUpdate, + + /// + /// When the repository is being automatically updated. + /// + [Description("Scheduled repository update")] + RepositoryAutoUpdate, + + /// + /// When the repository is being deleted. + /// + [Description("Delete repository")] + RepositoryDelete, + + /// + /// When a new official engine version is being installed. + /// + [Description("Install engine version")] + EngineOfficialInstall, + + /// + /// When a new custom engine version is being installed. + /// + [Description("Install custom engine version")] + EngineCustomInstall, + + /// + /// When an installed engine version is being deleted. + /// + [Description("Delete installed engine version")] + EngineDelete, + + /// + /// When a deployment is manually triggered. + /// + [Description("Compile active repository code")] + Deployment, + + /// + /// When a deployment is automatically triggered. + /// + [Description("Scheduled code deployment")] + AutomaticDeployment, + + /// + /// When the watchdog is started manually. + /// + [Description("Launch Watchdog")] + WatchdogLaunch, + + /// + /// When the watchdog is restarted manually. + /// + [Description("Restart Watchdog")] + WatchdogRestart, + + /// + /// When a the watchdog is dumping the game server process. + /// + [Description("Create DreamDaemon Process Dump")] + WatchdogDump, + + /// + /// When the watchdog starts due to an instance being onlined. + /// + [Description("Instance startup watchdog launch")] + StartupWatchdogLaunch, + + /// + /// When the watchdog reattaches due to an instance being onlined. + /// + [Description("Instance startup watchdog reattach")] + StartupWatchdogReattach, + + /// + /// When a chat bot connects/reconnects. + /// + [Description("Reconnect chat bot")] + ReconnectChatBot, + } +} diff --git a/src/Tgstation.Server.Api/Models/Request/UserCreateRequest.cs b/src/Tgstation.Server.Api/Models/Request/UserCreateRequest.cs index 5f3b4e09da..9c35ee12ce 100644 --- a/src/Tgstation.Server.Api/Models/Request/UserCreateRequest.cs +++ b/src/Tgstation.Server.Api/Models/Request/UserCreateRequest.cs @@ -3,7 +3,6 @@ /// /// For creating a user. /// -#pragma warning disable CA1501 public sealed class UserCreateRequest : UserUpdateRequest { } diff --git a/src/Tgstation.Server.Api/Models/Response/CompileJobResponse.cs b/src/Tgstation.Server.Api/Models/Response/CompileJobResponse.cs index 0ad0283749..7f3415bb5b 100644 --- a/src/Tgstation.Server.Api/Models/Response/CompileJobResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/CompileJobResponse.cs @@ -17,12 +17,6 @@ namespace Tgstation.Server.Api.Models.Response /// public RevisionInformation? RevisionInformation { get; set; } - /// - /// The the was made with. - /// - [Obsolete("Use EngineVersion instead.")] - public string? ByondVersion { get; set; } - /// /// The the was made with. /// diff --git a/src/Tgstation.Server.Api/Models/Response/JobResponse.cs b/src/Tgstation.Server.Api/Models/Response/JobResponse.cs index eb194af473..053fe1f9aa 100644 --- a/src/Tgstation.Server.Api/Models/Response/JobResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/JobResponse.cs @@ -5,6 +5,11 @@ /// public sealed class JobResponse : Internal.Job { + /// + /// The of the . + /// + public long? InstanceId { get; set; } + /// /// The that started the job. /// diff --git a/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs b/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs index cd143ede8c..8f7b01762b 100644 --- a/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs @@ -1,5 +1,7 @@ using System; +using Microsoft.IdentityModel.JsonWebTokens; + namespace Tgstation.Server.Api.Models.Response { /// @@ -15,6 +17,13 @@ namespace Tgstation.Server.Api.Models.Response /// /// When the expires. /// - public DateTimeOffset ExpiresAt { get; set; } + [Obsolete("Will be removed in a future API version")] + public DateTimeOffset? ExpiresAt { get; set; } + + /// + /// Parses the as a . + /// + /// A new based on . + public JsonWebToken ParseJwt() => new (Bearer); } } diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index 9df78bb81b..2f60e9a0a0 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; namespace Tgstation.Server.Api.Rights { @@ -32,6 +33,15 @@ namespace Tgstation.Server.Api.Rights /// The of the given . public static Type RightToType(RightsType rightsType) => TypeMap[rightsType]; + /// + /// Map a given to its respective . + /// + /// The of the right. + /// The of . + public static RightsType TypeToRight() + where TRight : Enum + => TypeMap.First(kvp => kvp.Value == typeof(TRight)).Key; + /// /// Gets the role claim name used for a given . /// diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index ece56b6876..e4a5f75a0a 100644 --- a/src/Tgstation.Server.Api/Routes.cs +++ b/src/Tgstation.Server.Api/Routes.cs @@ -12,6 +12,11 @@ namespace Tgstation.Server.Api /// public const string Root = "/"; + /// + /// The root route of all hubs. + /// + public const string HubsRoot = Root + "hubs"; + /// /// The server administration controller. /// @@ -102,6 +107,11 @@ namespace Tgstation.Server.Api /// public const string List = "List"; + /// + /// The root route of all hubs. + /// + public const string JobsHub = HubsRoot + "/jobs"; + /// /// Apply an postfix to a . /// diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index 582c079b6e..e499846add 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -26,6 +26,8 @@ + + diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index a235496592..4f6be11b5a 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -11,6 +11,10 @@ using System.Threading; using System.Threading.Tasks; using System.Web; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Newtonsoft.Json; @@ -20,6 +24,7 @@ using Newtonsoft.Json.Serialization; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Client.Extensions; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Common.Http; @@ -51,6 +56,18 @@ namespace Tgstation.Server.Client set => httpClient.Timeout = value; } + /// + /// The to use. + /// + static readonly JsonSerializerSettings SerializerSettings = new () + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new[] + { + new VersionConverter(), + }, + }; + /// /// The for the . /// @@ -61,6 +78,11 @@ namespace Tgstation.Server.Client /// readonly List requestLoggers; + /// + /// List of s created by the . + /// + readonly List hubConnections; + /// /// Backing field for . /// @@ -82,14 +104,9 @@ namespace Tgstation.Server.Client ApiHeaders headers; /// - /// Get the to use. + /// If the is disposed. /// - /// A new instance. - static JsonSerializerSettings GetSerializerSettings() => new () - { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - Converters = new[] { new VersionConverter() }, - }; + bool disposed; /// /// Handle a bad HTTP . @@ -102,7 +119,7 @@ namespace Tgstation.Server.Client try { // check if json serializes to an error message - errorMessage = JsonConvert.DeserializeObject(json, GetSerializerSettings()); + errorMessage = JsonConvert.DeserializeObject(json, SerializerSettings); } catch (JsonException) { @@ -149,7 +166,12 @@ namespace Tgstation.Server.Client /// The value of . /// The value of . /// The value of . - public ApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders, bool authless) + public ApiClient( + IHttpClient httpClient, + Uri url, + ApiHeaders apiHeaders, + ApiHeaders? tokenRefreshHeaders, + bool authless) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); Url = url ?? throw new ArgumentNullException(nameof(url)); @@ -158,12 +180,27 @@ namespace Tgstation.Server.Client this.authless = authless; requestLoggers = new List(); + hubConnections = new List(); semaphoreSlim = new SemaphoreSlim(1); } /// - public void Dispose() + public async ValueTask DisposeAsync() { + List localHubConnections; + lock (hubConnections) + { + if (disposed) + return; + + disposed = true; + + localHubConnections = hubConnections.ToList(); + hubConnections.Clear(); + } + + await ValueTaskExtensions.WhenAll(hubConnections.Select(connection => connection.DisposeAsync())); + httpClient.Dispose(); semaphoreSlim.Dispose(); } @@ -294,6 +331,130 @@ namespace Tgstation.Server.Client } } + /// + /// Attempt to refresh the stored Bearer token in . + /// + /// The for the operation. + /// A resulting in if the refresh was successful, if a refresh is unable to be performed. + public async ValueTask RefreshToken(CancellationToken cancellationToken) + { + if (tokenRefreshHeaders == null) + return false; + + var startingToken = headers.Token; + await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (startingToken != headers.Token) + return true; + + var token = await RunRequest(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken).ConfigureAwait(false); + headers = new ApiHeaders(headers.UserAgent!, token); + } + finally + { + semaphoreSlim.Release(); + } + + return true; + } + + /// + public async ValueTask CreateHubConnection( + THubImplementation hubImplementation, + IRetryPolicy? retryPolicy, + Action? loggingConfigureAction, + CancellationToken cancellationToken) + where THubImplementation : class + { + if (hubImplementation == null) + throw new ArgumentNullException(nameof(hubImplementation)); + + retryPolicy ??= new InfiniteThirtySecondMaxRetryPolicy(); + + var wrappedPolicy = new ApiClientTokenRefreshRetryPolicy(this, retryPolicy); + + HubConnection? hubConnection = null; + var hubConnectionBuilder = new HubConnectionBuilder() + .AddNewtonsoftJsonProtocol(options => + { + options.PayloadSerializerSettings = SerializerSettings; + }) + .WithAutomaticReconnect(wrappedPolicy) + .WithUrl( + new Uri(Url, Routes.JobsHub), + HttpTransportType.ServerSentEvents, + options => + { + options.AccessTokenProvider = async () => + { + // DCT: None available. + if (Headers.Token == null + || (Headers.Token.ParseJwt().ValidTo <= DateTime.UtcNow + && !await RefreshToken(CancellationToken.None))) + { + _ = hubConnection!.StopAsync(); // DCT: None available. + return null; + } + + return Headers.Token.Bearer; + }; + + options.CloseTimeout = Timeout; + + Headers.SetHubConnectionHeaders(options.Headers); + }); + + if (loggingConfigureAction != null) + hubConnectionBuilder.ConfigureLogging(loggingConfigureAction); + + hubConnection = hubConnectionBuilder.Build(); + try + { + hubConnection.Closed += async (error) => + { + if (error is HttpRequestException httpRequestException) + { + // .StatusCode isn't in netstandard but fuck the police + var property = error.GetType().GetProperty("StatusCode"); + if (property != null) + { + var statusCode = (HttpStatusCode?)property.GetValue(error); + if (statusCode == HttpStatusCode.Unauthorized + && !await RefreshToken(CancellationToken.None)) + _ = hubConnection!.StopAsync(); + } + } + }; + + hubConnection.ProxyOn(hubImplementation); + + Task startTask; + lock (hubConnections) + { + if (disposed) + throw new ObjectDisposedException(nameof(ApiClient)); + + hubConnections.Add(hubConnection); + startTask = hubConnection.StartAsync(cancellationToken); + } + + await startTask; + + return hubConnection; + } + catch + { + bool needsDispose; + lock (hubConnections) + needsDispose = hubConnections.Remove(hubConnection); + + if (needsDispose) + await hubConnection.DisposeAsync(); + throw; + } + } + /// /// Main request method. /// @@ -305,6 +466,7 @@ namespace Tgstation.Server.Client /// If this is a token refresh operation. /// The for the operation. /// A resulting in the response on success. +#pragma warning disable CA1506 // TODO: Decomplexify protected virtual async ValueTask RunRequest( string route, HttpContent? content, @@ -320,9 +482,12 @@ namespace Tgstation.Server.Client if (content == null && (method == HttpMethod.Post || method == HttpMethod.Put)) throw new InvalidOperationException("content cannot be null for POST or PUT!"); + if (disposed) + throw new ObjectDisposedException(nameof(ApiClient)); + HttpResponseMessage response; var fullUri = new Uri(Url, route); - var serializerSettings = GetSerializerSettings(); + var serializerSettings = SerializerSettings; var fileDownload = typeof(TResult) == typeof(Stream); using (var request = new HttpRequestMessage(method, fullUri)) { @@ -336,6 +501,21 @@ namespace Tgstation.Server.Client if (authless) request.Headers.Remove(HeaderNames.Authorization); + else + { + var bearer = headersToUse.Token?.Bearer; + if (bearer != null) + { + var parsed = headersToUse.Token!.ParseJwt(); + var nbf = parsed.ValidFrom; + var now = DateTime.UtcNow; + if (nbf >= now) + { + var delay = (nbf - now).Add(TimeSpan.FromMilliseconds(1)); + await Task.Delay(delay, cancellationToken); + } + } + } if (fileDownload) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet)); @@ -392,38 +572,7 @@ namespace Tgstation.Server.Client } } } - - /// - /// Attempt to refresh the bearer token in the . - /// - /// The for the operation. - /// A resulting in if the refresh was successful, otherwise. - async ValueTask RefreshToken(CancellationToken cancellationToken) - { - if (tokenRefreshHeaders == null) - return false; - - var startingToken = headers.Token; - await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - if (startingToken != headers.Token) - return true; - - var token = await RunRequest(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken).ConfigureAwait(false); - headers = new ApiHeaders(headers.UserAgent!, token.Bearer!); - } - catch (ClientException) - { - return false; - } - finally - { - semaphoreSlim.Release(); - } - - return true; - } +#pragma warning restore CA1506 /// /// Main request method. @@ -449,7 +598,7 @@ namespace Tgstation.Server.Client HttpContent? content = null; if (body != null) content = new StringContent( - JsonConvert.SerializeObject(body, typeof(TBody), Formatting.None, GetSerializerSettings()), + JsonConvert.SerializeObject(body, typeof(TBody), Formatting.None, SerializerSettings), Encoding.UTF8, ApiHeaders.ApplicationJsonMime); diff --git a/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs new file mode 100644 index 0000000000..34556c24ec --- /dev/null +++ b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; + +using Microsoft.AspNetCore.SignalR.Client; + +namespace Tgstation.Server.Client +{ + /// + /// A that attempts to refresh a given 's token on the first disconnect. + /// + sealed class ApiClientTokenRefreshRetryPolicy : IRetryPolicy + { + /// + /// The backing . + /// + readonly ApiClient apiClient; + + /// + /// The wrapped . + /// + readonly IRetryPolicy wrappedPolicy; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public ApiClientTokenRefreshRetryPolicy(ApiClient apiClient, IRetryPolicy wrappedPolicy) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.wrappedPolicy = wrappedPolicy ?? throw new ArgumentNullException(nameof(wrappedPolicy)); + } + + /// + public TimeSpan? NextRetryDelay(RetryContext retryContext) + { + if (retryContext == null) + throw new ArgumentNullException(nameof(retryContext)); + + if (retryContext.PreviousRetryCount == 0) + AttemptTokenRefresh(); + + return wrappedPolicy.NextRetryDelay(retryContext); + } + + /// + /// Attempt to refresh the s token asynchronously. + /// + async void AttemptTokenRefresh() + { + try + { + await apiClient.RefreshToken(CancellationToken.None); + } + catch + { + // intentionally ignored + } + } + } +} diff --git a/src/Tgstation.Server.Client/Extensions/HubConnectionExtensions.cs b/src/Tgstation.Server.Client/Extensions/HubConnectionExtensions.cs new file mode 100644 index 0000000000..44351272f0 --- /dev/null +++ b/src/Tgstation.Server.Client/Extensions/HubConnectionExtensions.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR.Client; + +namespace Tgstation.Server.Client.Extensions +{ + /// + /// Extension methods for the . + /// + static class HubConnectionExtensions + { + /// + /// Apply a given to a given . + /// + /// The strongly typed client proxy. + /// The to proxy on. + /// The to forward operations to. + public static void ProxyOn(this HubConnection hubConnection, TClientProxy proxy) + where TClientProxy : class + { + if (hubConnection == null) + throw new ArgumentNullException(nameof(hubConnection)); + + if (proxy == null) + throw new ArgumentNullException(nameof(proxy)); + + ProxyOn(hubConnection, typeof(TClientProxy), proxy); + } + + /// + /// Apply a given to a given . + /// + /// The to proxy on. + /// The of . + /// The to forward operations to. + static void ProxyOn(this HubConnection hubConnection, Type proxyType, object proxyObject) + { + var clientMethods = proxyType.GetMethods(); + var cancellationTokenType = typeof(CancellationToken); + foreach (var clientMethod in clientMethods) + { + var parametersList = clientMethod + .GetParameters() + .Select(parameterInfo => parameterInfo.ParameterType) + .ToList(); + + var cancellationTokenIndex = parametersList.IndexOf(cancellationTokenType); + if (cancellationTokenIndex != -1) + { + parametersList.RemoveAt(cancellationTokenIndex); +#if DEBUG + if (parametersList.IndexOf(cancellationTokenType) != -1) + throw new InvalidOperationException("Cannot ProxyOn a method with multiple CancellationToken parameters!"); +#endif + } + + var parameters = parametersList.ToArray(); + + object?[] AddCancellationTokenToParametersArray(object?[] parametersArray) + { + if (cancellationTokenIndex == -1) + return parametersArray; + + var newList = parametersArray.ToList(); + newList.Insert(cancellationTokenIndex, CancellationToken.None); + return newList.ToArray(); + } + + var returnType = clientMethod.ReturnType; + if (returnType != typeof(Task)) + { + if (returnType.BaseType != typeof(Task)) + throw new InvalidOperationException($"Return type {returnType} of {proxyType.FullName}.{clientMethod.Name} is not supported! Only Task and derivatives are supported."); + + var resultProperty = returnType.GetProperty(nameof(Task.Result)); + hubConnection.On( + clientMethod.Name, + parameters, + async (parameterArray, _) => + { + var task = (Task)clientMethod.Invoke(proxyObject, AddCancellationTokenToParametersArray(parameterArray)); + await task; + return resultProperty.GetValue(task); + }, + hubConnection); + } + else + hubConnection.On( + clientMethod.Name, + parameters, + (parameterArray) => + { + return (Task)clientMethod.Invoke(proxyObject, AddCancellationTokenToParametersArray(parameterArray)); + }); + } + + foreach (var inheritedInterface in proxyType.GetInterfaces()) + ProxyOn(hubConnection, inheritedInterface, proxyObject); + } + } +} diff --git a/src/Tgstation.Server.Client/IApiClient.cs b/src/Tgstation.Server.Client/IApiClient.cs index 836a9c7114..10d6477e3e 100644 --- a/src/Tgstation.Server.Client/IApiClient.cs +++ b/src/Tgstation.Server.Client/IApiClient.cs @@ -3,6 +3,9 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; + using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; @@ -12,7 +15,7 @@ namespace Tgstation.Server.Client /// /// Web interface for the API. /// - interface IApiClient : IDisposable + interface IApiClient : IAsyncDisposable { /// /// The the uses. @@ -35,6 +38,22 @@ namespace Tgstation.Server.Client /// The to add. void AddRequestLogger(IRequestLogger requestLogger); + /// + /// Subscribe to all job updates available to the . + /// + /// The of the hub being implemented. + /// The to use for proxying the methods of the hub connection. + /// The optional to use for the backing connection. The default retry policy waits for 1, 2, 4, 8, and 16 seconds, then 30s repeatedly. + /// The optional used to configure a . + /// The for the operation. + /// An representing the lifetime of the subscription. + ValueTask CreateHubConnection( + THubImplementation hubImplementation, + IRetryPolicy? retryPolicy, + Action? loggingConfigureAction, + CancellationToken cancellationToken) + where THubImplementation : class; + /// /// Run an HTTP PUT request. /// diff --git a/src/Tgstation.Server.Client/IApiClientFactory.cs b/src/Tgstation.Server.Client/IApiClientFactory.cs index 67e1b2aa52..e49385dcf9 100644 --- a/src/Tgstation.Server.Client/IApiClientFactory.cs +++ b/src/Tgstation.Server.Client/IApiClientFactory.cs @@ -17,6 +17,10 @@ namespace Tgstation.Server.Client /// The to use to generate a new . /// If there should be no authentication performed. /// A new . - IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders, bool authless); + IApiClient CreateApiClient( + Uri url, + ApiHeaders apiHeaders, + ApiHeaders? tokenRefreshHeaders, + bool authless); } } diff --git a/src/Tgstation.Server.Client/IServerClient.cs b/src/Tgstation.Server.Client/IServerClient.cs index f5dc821365..65014f66b1 100644 --- a/src/Tgstation.Server.Client/IServerClient.cs +++ b/src/Tgstation.Server.Client/IServerClient.cs @@ -2,6 +2,10 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client @@ -9,7 +13,7 @@ namespace Tgstation.Server.Client /// /// Main client for communicating with a server. /// - public interface IServerClient : IDisposable + public interface IServerClient : IAsyncDisposable { /// /// The connected server . @@ -53,6 +57,20 @@ namespace Tgstation.Server.Client /// A resulting in the of the target server. ValueTask ServerInformation(CancellationToken cancellationToken); + /// + /// Subscribe to all job updates available to the . + /// + /// The to use to subscribe to updates. + /// The optional to use for the backing connection. The default retry policy waits for 1, 2, 4, 8, and 16 seconds, then 30s repeatedly. + /// The optional used to configure a . + /// The for the operation. + /// An representing the lifetime of the subscription. + ValueTask SubscribeToJobUpdates( + IJobsHub jobsReceiver, + IRetryPolicy? retryPolicy = null, + Action? loggingConfigureAction = null, + CancellationToken cancellationToken = default); + /// /// Adds a to the request pipeline. /// diff --git a/src/Tgstation.Server.Client/InfiniteThirtySecondMaxRetryPolicy.cs b/src/Tgstation.Server.Client/InfiniteThirtySecondMaxRetryPolicy.cs new file mode 100644 index 0000000000..8452631306 --- /dev/null +++ b/src/Tgstation.Server.Client/InfiniteThirtySecondMaxRetryPolicy.cs @@ -0,0 +1,21 @@ +using System; + +using Microsoft.AspNetCore.SignalR.Client; + +namespace Tgstation.Server.Client +{ + /// + /// A that returns seconds in powers of 2, maxing out at 30s. + /// + sealed class InfiniteThirtySecondMaxRetryPolicy : IRetryPolicy + { + /// + public TimeSpan? NextRetryDelay(RetryContext retryContext) + { + if (retryContext == null) + throw new ArgumentNullException(nameof(retryContext)); + + return TimeSpan.FromSeconds(Math.Min(Math.Pow(2, retryContext.PreviousRetryCount), 30)); + } + } +} diff --git a/src/Tgstation.Server.Client/ServerClient.cs b/src/Tgstation.Server.Client/ServerClient.cs index 81111d475b..186e32745b 100644 --- a/src/Tgstation.Server.Client/ServerClient.cs +++ b/src/Tgstation.Server.Client/ServerClient.cs @@ -2,7 +2,11 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; + using Tgstation.Server.Api; +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client @@ -16,12 +20,8 @@ namespace Tgstation.Server.Client /// public TokenResponse Token { - get => token; - set - { - token = value ?? throw new InvalidOperationException("Cannot set a null Token!"); - apiClient.Headers = new ApiHeaders(apiClient.Headers.UserAgent!, token.Bearer!); - } + get => apiClient.Headers.Token ?? throw new InvalidOperationException("apiClient.Headers.Token was null!"); + set => apiClient.Headers = new ApiHeaders(apiClient.Headers.UserAgent!, value); } /// @@ -48,23 +48,13 @@ namespace Tgstation.Server.Client /// readonly IApiClient apiClient; - /// - /// Backing field for . - /// - TokenResponse token; - /// /// Initializes a new instance of the class. /// /// The value of . - /// The value of . - public ServerClient(IApiClient apiClient, TokenResponse token) + public ServerClient(IApiClient apiClient) { this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - this.token = token ?? throw new ArgumentNullException(nameof(token)); - - if (Token.Bearer != apiClient.Headers.Token) - throw new ArgumentOutOfRangeException(nameof(token), token, "Provided token does not match apiClient headers!"); Instances = new InstanceManagerClient(apiClient); Users = new UsersClient(apiClient); @@ -73,12 +63,20 @@ namespace Tgstation.Server.Client } /// - public void Dispose() => apiClient.Dispose(); + public ValueTask DisposeAsync() => apiClient.DisposeAsync(); /// public ValueTask ServerInformation(CancellationToken cancellationToken) => apiClient.Read(Routes.Root, cancellationToken); /// public void AddRequestLogger(IRequestLogger requestLogger) => apiClient.AddRequestLogger(requestLogger); + + /// + public ValueTask SubscribeToJobUpdates( + IJobsHub jobsReceiver, + IRetryPolicy? retryPolicy, + Action? loggingConfigureAction, + CancellationToken cancellationToken) + => apiClient.CreateHubConnection(jobsReceiver, retryPolicy, loggingConfigureAction, cancellationToken); } } diff --git a/src/Tgstation.Server.Client/ServerClientFactory.cs b/src/Tgstation.Server.Client/ServerClientFactory.cs index 3dcb6ec67d..155198996a 100644 --- a/src/Tgstation.Server.Client/ServerClientFactory.cs +++ b/src/Tgstation.Server.Client/ServerClientFactory.cs @@ -102,7 +102,15 @@ namespace Tgstation.Server.Client if (token.Bearer == null) throw new InvalidOperationException("token.Bearer should not be null!"); - return new ServerClient(ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer), null, false), token); + var serverClient = new ServerClient( + ApiClientFactory.CreateApiClient( + host, + new ApiHeaders( + productHeaderValue, + token), + null, + false)); + return serverClient; } /// @@ -112,7 +120,16 @@ namespace Tgstation.Server.Client TimeSpan? timeout = null, CancellationToken cancellationToken = default) { - using var api = ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, "fake"), null, true); + await using var api = ApiClientFactory.CreateApiClient( + host, + new ApiHeaders( + productHeaderValue, + new TokenResponse + { + Bearer = "unused", + }), + null, + true); if (requestLoggers != null) foreach (var requestLogger in requestLoggers) @@ -145,7 +162,7 @@ namespace Tgstation.Server.Client requestLoggers ??= Enumerable.Empty(); TokenResponse token; - using (var api = ApiClientFactory.CreateApiClient(host, loginHeaders, null, false)) + await using (var api = ApiClientFactory.CreateApiClient(host, loginHeaders, null, false)) { foreach (var requestLogger in requestLoggers) api.AddRequestLogger(requestLogger); @@ -155,14 +172,13 @@ namespace Tgstation.Server.Client token = await api.Update(Routes.Root, cancellationToken).ConfigureAwait(false); } - var apiHeaders = new ApiHeaders(productHeaderValue, token.Bearer!); + var apiHeaders = new ApiHeaders(productHeaderValue, token); var client = new ServerClient( ApiClientFactory.CreateApiClient( host, apiHeaders, attemptLoginRefresh ? loginHeaders : null, - false), - token); + false)); if (timeout.HasValue) client.Timeout = timeout.Value; diff --git a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj index 6868098fa6..375fb7161a 100644 --- a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj +++ b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj @@ -9,6 +9,13 @@ $(TGS_NUGET_RELEASE_NOTES_CLIENT) + + + + + + + diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/ByondCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/ByondCommand.cs deleted file mode 100644 index 6e4f64f3f9..0000000000 --- a/src/Tgstation.Server.Host/Components/Chat/Commands/ByondCommand.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -using Tgstation.Server.Api.Models; -using Tgstation.Server.Host.Components.Engine; -using Tgstation.Server.Host.Components.Interop; -using Tgstation.Server.Host.Components.Watchdog; - -namespace Tgstation.Server.Host.Components.Chat.Commands -{ - /// - /// For displaying the installed Byond version. - /// - sealed class ByondCommand : ICommand - { - /// - public string Name => "byond"; - - /// - public string HelpText => "Displays the running Byond version. Use --active for the version used in future deployments"; - - /// - public bool AdminOnly => false; - - /// - /// The for the . - /// - readonly IEngineManager engineManager; - - /// - /// The for the . - /// - readonly IWatchdog watchdog; - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - /// The value of . - public ByondCommand(IEngineManager engineManager, IWatchdog watchdog) - { - this.engineManager = engineManager ?? throw new ArgumentNullException(nameof(engineManager)); - this.watchdog = watchdog ?? throw new ArgumentNullException(nameof(watchdog)); - } - - /// - public ValueTask Invoke(string arguments, ChatUser user, CancellationToken cancellationToken) - { - if (arguments.Split(' ').Any(x => x.Equals("--active", StringComparison.OrdinalIgnoreCase))) - { - string text; - if (engineManager.ActiveVersion == null) - text = "None!"; - else - switch (engineManager.ActiveVersion.Engine.Value) - { - case EngineType.OpenDream: - text = $"OpenDream: {engineManager.ActiveVersion.SourceSHA}"; - break; - case EngineType.Byond: - text = $"BYOND {engineManager.ActiveVersion.Version.Major}.{engineManager.ActiveVersion.Version.Minor}"; - if (engineManager.ActiveVersion.CustomIteration.HasValue) - text += " (Custom Build)"; - - break; - default: - throw new InvalidOperationException($"Invalid EngineType: {engineManager.ActiveVersion.Engine.Value}"); - } - - return ValueTask.FromResult( - new MessageContent - { - Text = text, - }); - } - - if (watchdog.Status == WatchdogStatus.Offline) - return ValueTask.FromResult( - new MessageContent - { - Text = "Server offline!", - }); - return ValueTask.FromResult( - new MessageContent - { - Text = watchdog.ActiveCompileJob?.ByondVersion ?? "None!", - }); - } - } -} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs index cb75153b74..d4fda66603 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs @@ -92,7 +92,7 @@ namespace Tgstation.Server.Host.Components.Chat.Commands return new List { new VersionCommand(assemblyInformationProvider), - new ByondCommand(engineManager, watchdog), + new EngineCommand(engineManager, watchdog), new RevisionCommand(watchdog, repositoryManager), new PullRequestsCommand(watchdog, repositoryManager, databaseContextFactory, compileJobProvider, instance), new KekCommand(), diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/EngineCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/EngineCommand.cs new file mode 100644 index 0000000000..d29e45f9bd --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/EngineCommand.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Components.Engine; +using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Components.Watchdog; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// For displaying the installed Byond version. + /// + sealed class EngineCommand : ICommand + { + /// + public string Name => "engine"; + + /// + public string HelpText => "Displays the running engine version. Use --active for the version used in future deployments"; + + /// + public bool AdminOnly => false; + + /// + /// The for the . + /// + readonly IEngineManager engineManager; + + /// + /// The for the . + /// + readonly IWatchdog watchdog; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public EngineCommand(IEngineManager engineManager, IWatchdog watchdog) + { + this.engineManager = engineManager ?? throw new ArgumentNullException(nameof(engineManager)); + this.watchdog = watchdog ?? throw new ArgumentNullException(nameof(watchdog)); + } + + /// + public ValueTask Invoke(string arguments, ChatUser user, CancellationToken cancellationToken) + { + EngineVersion engineVersion; + if (arguments.Split(' ').Any(x => x.Equals("--active", StringComparison.OrdinalIgnoreCase))) + engineVersion = engineManager.ActiveVersion; + else + { + if (watchdog.Status == WatchdogStatus.Offline) + return ValueTask.FromResult( + new MessageContent + { + Text = "Server offline!", + }); + + if (watchdog.ActiveCompileJob == null) + return ValueTask.FromResult( + new MessageContent + { + Text = "None!", + }); + + if (!EngineVersion.TryParse(watchdog.ActiveCompileJob.EngineVersion, out engineVersion)) + throw new InvalidOperationException($"Invalid engine version: {watchdog.ActiveCompileJob.EngineVersion}"); + } + + string text; + if (engineVersion == null) + text = "None!"; + else + text = engineVersion.Engine.Value switch + { + EngineType.OpenDream => $"OpenDream: {engineVersion.SourceSHA}", + EngineType.Byond => $"BYOND {engineVersion.Version.Major}.{engineVersion.Version.Minor}", + _ => throw new InvalidOperationException($"Invalid EngineType: {engineVersion.Engine.Value}"), + }; + + if (engineVersion.CustomIteration.HasValue) + text += $" (Custom Upload #{engineVersion.CustomIteration.Value})"; + + return ValueTask.FromResult( + new MessageContent + { + Text = text, + }); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index a4be368ea2..cd600eea53 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -336,7 +336,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { var completionString = errorMessage == null ? "Pending" : "Failed"; - Embed CreateUpdatedEmbed(string message, Color color) => new Embed + Embed CreateUpdatedEmbed(string message, Color color) => new () { Author = embed.Author, Colour = color, diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index fae8002f5c..2e61723c94 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -352,7 +352,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers dbChannel, new List { - new ChannelRepresentation + new () { RealId = id.Value, IsAdminChannel = dbChannel.IsAdminChannel == true, diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs index 0dceac0dfb..8279c14d97 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs @@ -287,13 +287,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers connectNow = false; if (!Connected) { - var job = new Models.Job - { - Description = $"Reconnect chat bot: {ChatBot.Name}", - CancelRight = (ulong)ChatBotRights.WriteEnabled, - CancelRightsType = RightsType.ChatBots, - Instance = ChatBot.Instance, - }; + var job = Models.Job.Create(Api.Models.JobCode.ReconnectChatBot, null, ChatBot.Instance, ChatBotRights.WriteEnabled); + job.Description += $": {ChatBot.Name}"; await jobManager.RegisterOperation( job, diff --git a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index d2165084e9..13db5c999d 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs @@ -239,6 +239,8 @@ namespace Tgstation.Server.Host.Components.Deployment .Where(x => x.Id == compileJob.Id) .Include(x => x.Job) .ThenInclude(x => x.StartedBy) + .Include(x => x.Job) + .ThenInclude(x => x.Instance) .Include(x => x.RevisionInformation) .ThenInclude(x => x.PrimaryTestMerge) .ThenInclude(x => x.MergedBy) @@ -248,9 +250,9 @@ namespace Tgstation.Server.Host.Components.Deployment .ThenInclude(x => x.MergedBy) .FirstAsync(cancellationToken)); // can't wait to see that query - if (!Api.Models.Internal.EngineVersion.TryParse(compileJob.ByondVersion, out var engineVersion)) + if (!Api.Models.Internal.EngineVersion.TryParse(compileJob.EngineVersion, out var engineVersion)) { - logger.LogWarning("Error loading compile job, bad engine version: {0}", compileJob.ByondVersion); + logger.LogWarning("Error loading compile job, bad engine version: {0}", compileJob.EngineVersion); return null; // omae wa mou shinderu } diff --git a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs index e9a88a1c7f..3a8a256fd1 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs @@ -332,10 +332,7 @@ namespace Tgstation.Server.Host.Components.Deployment async databaseContext => { var fullJob = compileJob.Job; - compileJob.Job = new Models.Job - { - Id = job.Id, - }; + compileJob.Job = new Models.Job(job.Id.Value); var fullRevInfo = compileJob.RevisionInformation; compileJob.RevisionInformation = new Models.RevisionInformation { @@ -426,10 +423,10 @@ namespace Tgstation.Server.Host.Components.Deployment .Where(x => x.Job.Instance.Id == metadata.Id) .OrderByDescending(x => x.Job.StoppedAt) .Take(10) - .Select(x => new Models.Job + .Select(x => new { - StoppedAt = x.Job.StoppedAt, - StartedAt = x.Job.StartedAt, + x.Job.StoppedAt, + x.Job.StartedAt, }) .ToListAsync(cancellationToken); @@ -491,7 +488,7 @@ namespace Tgstation.Server.Host.Components.Deployment DirectoryName = Guid.NewGuid(), DmeName = dreamMakerSettings.ProjectName, RevisionInformation = revisionInformation, - ByondVersion = engineLock.Version.ToString(), + EngineVersion = engineLock.Version.ToString(), RepositoryOrigin = repository.Origin.ToString(), }; diff --git a/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs b/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs index b8253d412d..6751f18184 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/Remote/NoOpRemoteDeploymentManager.cs @@ -32,10 +32,7 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote } /// - public override ValueTask FailDeployment(Models.CompileJob compileJob, string errorMessage, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + public override ValueTask FailDeployment(Models.CompileJob compileJob, string errorMessage, CancellationToken cancellationToken) => ValueTask.CompletedTask; /// public override ValueTask> RemoveMergedTestMerges(IRepository repository, Models.RepositorySettings repositorySettings, Models.RevisionInformation revisionInformation, CancellationToken cancellationToken) @@ -69,10 +66,7 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote => String.Empty; /// - protected override ValueTask MarkInactiveImpl(Models.CompileJob compileJob, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } + protected override ValueTask MarkInactiveImpl(Models.CompileJob compileJob, CancellationToken cancellationToken) => ValueTask.CompletedTask; /// protected override ValueTask StageDeploymentImpl(Models.CompileJob compileJob, CancellationToken cancellationToken) => ValueTask.CompletedTask; diff --git a/src/Tgstation.Server.Host/Components/Events/EventType.cs b/src/Tgstation.Server.Host/Components/Events/EventType.cs index e7193a34d3..2d34b552c2 100644 --- a/src/Tgstation.Server.Host/Components/Events/EventType.cs +++ b/src/Tgstation.Server.Host/Components/Events/EventType.cs @@ -161,5 +161,11 @@ /// [EventScript("DeploymentCleanup")] DeploymentCleanup, + + /// + /// Whenever a deployment is about to be used by the game server. May fire multiple times per deployment. Parameters: Game directory path + /// + [EventScript("DeploymentActivation")] + DeploymentActivation, } } diff --git a/src/Tgstation.Server.Host/Components/IInstanceReference.cs b/src/Tgstation.Server.Host/Components/IInstanceReference.cs index a6453747f8..55f9379e80 100644 --- a/src/Tgstation.Server.Host/Components/IInstanceReference.cs +++ b/src/Tgstation.Server.Host/Components/IInstanceReference.cs @@ -10,6 +10,6 @@ namespace Tgstation.Server.Host.Components /// /// A unique ID for the . /// - public Guid Uid { get; } + public ulong Uid { get; } } } diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index 59d0becaee..7176e8f050 100644 --- a/src/Tgstation.Server.Host/Components/Instance.cs +++ b/src/Tgstation.Server.Host/Components/Instance.cs @@ -501,17 +501,7 @@ namespace Tgstation.Server.Host.Components await eventConsumer.HandleEvent(EventType.InstanceAutoUpdateStart, Enumerable.Empty(), true, cancellationToken); try { - var repositoryUpdateJob = new Job - { - Instance = new Models.Instance - { - Id = metadata.Id, - }, - Description = "Scheduled repository update", - CancelRightsType = RightsType.Repository, - CancelRight = (ulong)RepositoryRights.CancelPendingChanges, - }; - + var repositoryUpdateJob = Job.Create(Api.Models.JobCode.RepositoryAutoUpdate, null, metadata, RepositoryRights.CancelPendingChanges); await jobManager.RegisterOperation( repositoryUpdateJob, RepositoryAutoUpdateJob, @@ -541,14 +531,7 @@ namespace Tgstation.Server.Host.Components } // finally set up the job - compileProcessJob = new Job - { - Instance = repositoryUpdateJob.Instance, - Description = "Scheduled code deployment", - CancelRightsType = RightsType.DreamMaker, - CancelRight = (ulong)DreamMakerRights.CancelCompile, - }; - + compileProcessJob = Job.Create(Api.Models.JobCode.AutomaticDeployment, null, metadata, DreamMakerRights.CancelCompile); await jobManager.RegisterOperation( compileProcessJob, (core, databaseContextFactory, job, progressReporter, jobCancellationToken) => diff --git a/src/Tgstation.Server.Host/Components/InstanceManager.cs b/src/Tgstation.Server.Host/Components/InstanceManager.cs index aebd036f9b..18dbd43fdb 100644 --- a/src/Tgstation.Server.Host/Components/InstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/InstanceManager.cs @@ -359,10 +359,7 @@ namespace Tgstation.Server.Host.Components .Jobs .AsQueryable() .Where(x => x.Instance.Id == metadata.Id && !x.StoppedAt.HasValue) - .Select(x => new Models.Job - { - Id = x.Id, - }) + .Select(x => new Models.Job(x.Id.Value)) .ToListAsync(cancellationToken); foreach (var job in jobs) tasks.Add(jobService.CancelJob(job, user, true, cancellationToken)); diff --git a/src/Tgstation.Server.Host/Components/InstanceWrapper.cs b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs index 55f5ea7698..5c525d9869 100644 --- a/src/Tgstation.Server.Host/Components/InstanceWrapper.cs +++ b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Host.Components.Chat; @@ -18,8 +17,13 @@ namespace Tgstation.Server.Host.Components /// sealed class InstanceWrapper : ReferenceCounter, IInstanceReference { + /// + /// Static counter for . + /// + static ulong instanceWrapperInstances; + /// - public Guid Uid { get; } + public ulong Uid { get; } /// public IRepositoryManager RepositoryManager => Instance.RepositoryManager; @@ -44,7 +48,7 @@ namespace Tgstation.Server.Host.Components /// public InstanceWrapper() { - Uid = Guid.NewGuid(); + Uid = Interlocked.Increment(ref instanceWrapperInstances); } /// diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index a7165eed4f..2f046830d0 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -636,10 +636,12 @@ namespace Tgstation.Server.Host.Components.Session return; } - Logger.LogDebug("Server will terminated in 10s if it does not exit..."); - var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(10), CancellationToken.None); // DCT: None available - var completedTask = await Task.WhenAny(process.Lifetime, delayTask); - if (completedTask == delayTask) + const int GracePeriodSeconds = 30; + Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds); + var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None); // DCT: None available + await Task.WhenAny(process.Lifetime, delayTask); + + if (!process.Lifetime.IsCompleted) { Logger.LogWarning("DMAPI took too long to shutdown server after validation request!"); process.Terminate(); diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs index eddcd63973..5e22bb2a52 100644 --- a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs +++ b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs @@ -73,7 +73,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles /// static readonly IReadOnlyDictionary> EventTypeScriptFileNameMap = new Dictionary>( Enum.GetValues(typeof(EventType)) - .OfType() + .Cast() .Select( eventType => new KeyValuePair>( eventType, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs index 35d3e508e7..9d8f8cbda8 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs @@ -29,11 +29,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// protected SwappableDmbProvider ActiveSwappable { get; private set; } - /// - /// The for the pointing to the Game directory. - /// - protected IIOManager GameIOManager { get; } - /// /// The for the . /// @@ -64,10 +59,10 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . + /// The 'Diagnostics' for the . /// The for the . /// The for the . - /// The value of . + /// The 'Game' for the . /// The value of . /// The for the . /// The for the . @@ -101,6 +96,7 @@ namespace Tgstation.Server.Host.Components.Watchdog diagnosticsIOManager, eventConsumer, remoteDeploymentManagerFactory, + gameIOManager, logger, initialLaunchParameters, instance, @@ -108,7 +104,6 @@ namespace Tgstation.Server.Host.Components.Watchdog { try { - GameIOManager = gameIOManager ?? throw new ArgumentNullException(nameof(gameIOManager)); LinkFactory = linkFactory ?? throw new ArgumentNullException(nameof(linkFactory)); deploymentCleanupTasks = new List(); @@ -236,14 +231,14 @@ namespace Tgstation.Server.Host.Components.Watchdog IDmbProvider compileJobProvider = DmbFactory.LockNextDmb(1); bool canSeamlesslySwap = true; - if (compileJobProvider.CompileJob.ByondVersion != ActiveCompileJob.ByondVersion) + if (compileJobProvider.CompileJob.EngineVersion != ActiveCompileJob.EngineVersion) { // have to do a graceful restart Logger.LogDebug( "Not swapping to new compile job {0} as it uses a different BYOND version ({1}) than what is currently active {2}. Queueing graceful restart instead...", compileJobProvider.CompileJob.Id, - compileJobProvider.CompileJob.ByondVersion, - ActiveCompileJob.ByondVersion); + compileJobProvider.CompileJob.EngineVersion, + ActiveCompileJob.EngineVersion); canSeamlesslySwap = false; } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs index 046587273b..2fd1d3a5c7 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs @@ -50,9 +50,10 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The for the . /// The for the . /// The for the . - /// The for the . + /// The 'Diagnostics' for the . /// The for the . /// The for the . + /// The 'Game' for the . /// The for the . /// The for the . /// The for the . @@ -68,6 +69,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, + IIOManager gameIOManager, ILogger logger, DreamDaemonLaunchParameters initialLaunchParameters, Api.Models.Instance instance, @@ -83,6 +85,7 @@ namespace Tgstation.Server.Host.Components.Watchdog diagnosticsIOManager, eventConsumer, remoteDeploymentManagerFactory, + gameIOManager, logger, initialLaunchParameters, instance, diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index b7b72a31bd..97fad794f3 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -96,6 +96,11 @@ namespace Tgstation.Server.Host.Components.Watchdog /// protected IAsyncDelayer AsyncDelayer { get; } + /// + /// The for the pointing to the Game directory. + /// + protected IIOManager GameIOManager { get; } + /// /// The for the . /// @@ -184,6 +189,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The value of . /// The value of . /// The value of . + /// The value of . /// The value of . /// The initial value of . May be modified. /// The value of . @@ -199,6 +205,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IIOManager diagnosticsIOManager, IEventConsumer eventConsumer, IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory, + IIOManager gameIOManager, ILogger logger, DreamDaemonLaunchParameters initialLaunchParameters, Api.Models.Instance metadata, @@ -213,6 +220,7 @@ namespace Tgstation.Server.Host.Components.Watchdog this.diagnosticsIOManager = diagnosticsIOManager ?? throw new ArgumentNullException(nameof(diagnosticsIOManager)); this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory)); + GameIOManager = gameIOManager ?? throw new ArgumentNullException(nameof(gameIOManager)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); ActiveLaunchParameters = initialLaunchParameters ?? throw new ArgumentNullException(nameof(initialLaunchParameters)); this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); @@ -369,16 +377,13 @@ namespace Tgstation.Server.Host.Components.Watchdog if (!autoStart && !reattaching) return; - var job = new Models.Job - { - Instance = new Models.Instance - { - Id = metadata.Id, - }, - Description = $"Instance startup watchdog {(reattaching ? "reattach" : "launch")}", - CancelRight = (ulong)DreamDaemonRights.Shutdown, - CancelRightsType = RightsType.DreamDaemon, - }; + var job = Models.Job.Create( + reattaching + ? JobCode.StartupWatchdogReattach + : JobCode.StartupWatchdogLaunch, + null, + metadata, + DreamDaemonRights.Shutdown); await jobManager.RegisterOperation( job, async (core, databaseContextFactory, paramJob, progressFunction, ct) => @@ -679,6 +684,15 @@ namespace Tgstation.Server.Host.Components.Watchdog metadata, newCompileJob); + var eventTask = eventConsumer.HandleEvent( + EventType.DeploymentActivation, + new List + { + GameIOManager.ResolvePath(newCompileJob.DirectoryName.ToString()), + }, + false, + cancellationToken); + try { await remoteDeploymentManager.ApplyDeployment(newCompileJob, cancellationToken); @@ -687,6 +701,8 @@ namespace Tgstation.Server.Host.Components.Watchdog { Logger.LogWarning(ex, "Failed to apply remote deployment!"); } + + await eventTask; } /// diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs index 2fb7182755..e9a37ecb2b 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs @@ -90,6 +90,7 @@ namespace Tgstation.Server.Host.Components.Watchdog diagnosticsIOManager, eventConsumer, remoteDeploymentManagerfactory, + gameIOManager, LoggerFactory.CreateLogger(), settings, instance, diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 3685be3ec8..aa798cfed3 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -57,16 +57,17 @@ namespace Tgstation.Server.Host.Configuration ArgumentNullException.ThrowIfNull(assemblyInformationProvider); ArgumentNullException.ThrowIfNull(platformIdentifier); - var directoryToUse = platformIdentifier.IsWindows - ? Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) // C:/ProgramData - : "/var/log"; // :pain: + if (!String.IsNullOrEmpty(Directory)) + return Directory; - return !String.IsNullOrEmpty(Directory) - ? Directory - : ioManager.ConcatPath( - directoryToUse, + return platformIdentifier.IsWindows + ? ioManager.ConcatPath( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), assemblyInformationProvider.VersionPrefix, - "logs"); + "logs") + : ioManager.ConcatPath( + "/var/log", + assemblyInformationProvider.VersionPrefix); } } } diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index b742a2ae18..c1da69a69d 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -18,6 +18,7 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; @@ -25,6 +26,7 @@ using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.System; using Tgstation.Server.Host.Transfer; +using Tgstation.Server.Host.Utils; using Tgstation.Server.Host.Utils.GitHub; namespace Tgstation.Server.Host.Controllers @@ -84,7 +86,7 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The value of . /// The value of . /// The value of . @@ -94,9 +96,10 @@ namespace Tgstation.Server.Host.Controllers /// The value of . /// The for the . /// The containing value of . + /// The for the . public AdministrationController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, IGitHubServiceFactory gitHubServiceFactory, IServerControl serverControl, IServerUpdateInitiator serverUpdateInitiator, @@ -105,10 +108,12 @@ namespace Tgstation.Server.Host.Controllers IPlatformIdentifier platformIdentifier, IFileTransferTicketProvider fileTransferService, ILogger logger, - IOptions fileLoggingConfigurationOptions) + IOptions fileLoggingConfigurationOptions, + IApiHeadersProvider apiHeadersProvider) : base( databaseContext, - authenticationContextFactory, + authenticationContext, + apiHeadersProvider, logger, true) { diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index c0f37fbf82..ffa4e84960 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -3,13 +3,11 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; -using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.Logging; @@ -23,6 +21,7 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; @@ -34,9 +33,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Base for API functions. /// - [Produces(MediaTypeNames.Application.Json)] - [ApiController] - public abstract class ApiController : Controller + public abstract class ApiController : ApiControllerBase { /// /// Default size of results. @@ -51,7 +48,12 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the operation. /// - protected ApiHeaders ApiHeaders { get; private set; } + protected ApiHeaders ApiHeaders => ApiHeadersProvider.ApiHeaders; + + /// + /// The containing value of . + /// + protected IApiHeadersProvider ApiHeadersProvider { get; } /// /// The for the operation. @@ -82,69 +84,43 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The value of . - /// The for the . + /// The for the . /// The value of . + /// The value of .. /// The value of . protected ApiController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, + IApiHeadersProvider apiHeadersProvider, ILogger logger, bool requireHeaders) { DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); - ArgumentNullException.ThrowIfNull(authenticationContextFactory); + AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + ApiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - AuthenticationContext = authenticationContextFactory.CurrentAuthenticationContext; + Instance = AuthenticationContext?.InstancePermissionSet?.Instance; this.requireHeaders = requireHeaders; } /// #pragma warning disable CA1506 // TODO: Decomplexify - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + protected override async ValueTask HookExecuteAction(Func executeAction, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(context); - - // ALL valid token and login requests that match a route go through this function - // 404 is returned before - if (AuthenticationContext != null && AuthenticationContext.User == null) - { - // valid token, expired password - await Unauthorized().ExecuteResultAsync(context); - return; - } + ArgumentNullException.ThrowIfNull(executeAction); // validate the headers - try - { - ApiHeaders = new ApiHeaders(Request.GetTypedHeaders()); - - if (!ApiHeaders.Compatible()) - { - await this.StatusCode( - HttpStatusCode.UpgradeRequired, - new ErrorMessageResponse(ErrorCode.ApiMismatch)) - .ExecuteResultAsync(context); - return; - } - - var errorCase = await ValidateRequest(context.HttpContext.RequestAborted); - if (errorCase != null) - { - await errorCase.ExecuteResultAsync(context); - return; - } - } - catch (HeadersException) + if (ApiHeaders == null) { if (requireHeaders) - { - await HeadersIssue(false) - .ExecuteResultAsync(context); - return; - } + return HeadersIssue(ApiHeadersProvider.HeadersException); } + var errorCase = await ValidateRequest(cancellationToken); + if (errorCase != null) + return errorCase; + if (ModelState?.IsValid == false) { var errorMessages = ModelState @@ -158,15 +134,11 @@ namespace Tgstation.Server.Host.Controllers .Where(x => !x.EndsWith(" field is required.", StringComparison.Ordinal)); if (errorMessages.Any()) - { - await BadRequest( + return BadRequest( new ErrorMessageResponse(ErrorCode.ModelValidationFailure) { AdditionalData = String.Join(Environment.NewLine, errorMessages), - }) - .ExecuteResultAsync(context); - return; - } + }); ModelState.Clear(); } @@ -174,7 +146,7 @@ namespace Tgstation.Server.Host.Controllers using (ApiHeaders?.InstanceId != null ? LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, ApiHeaders.InstanceId) : null) - using (AuthenticationContext != null + using (AuthenticationContext.Valid ? LogContext.PushProperty(SerilogContextHelper.UserIdContextProperty, AuthenticationContext.User.Id) : null) using (LogContext.PushProperty(SerilogContextHelper.RequestPathContextProperty, $"{Request.Method} {Request.Path}")) @@ -182,15 +154,14 @@ namespace Tgstation.Server.Host.Controllers if (ApiHeaders != null) { var isGet = HttpMethods.IsGet(Request.Method); - if (!(isGet && Request.Path.StartsWithSegments(Routes.Jobs, StringComparison.OrdinalIgnoreCase))) - Logger.Log( - isGet - ? LogLevel.Trace - : LogLevel.Debug, - "Starting API request: Version: {clientApiVersion}. {userAgentHeaderName}: {clientUserAgent}", - ApiHeaders.ApiVersion.Semver(), - HeaderNames.UserAgent, - ApiHeaders.RawUserAgent); + Logger.Log( + isGet + ? LogLevel.Trace + : LogLevel.Debug, + "Starting API request: Version: {clientApiVersion}. {userAgentHeaderName}: {clientUserAgent}", + ApiHeaders.ApiVersion.Semver(), + HeaderNames.UserAgent, + ApiHeaders.RawUserAgent); } else if (Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgents)) Logger.LogDebug( @@ -201,17 +172,26 @@ namespace Tgstation.Server.Host.Controllers Logger.LogDebug( "Starting unauthorized API request. No {userAgentHeaderName}!", HeaderNames.UserAgent); - await base.OnActionExecutionAsync(context, next); + + await executeAction(); } + + return null; } #pragma warning restore CA1506 /// /// Generic 404 response. /// - /// An with . + /// A with an appropriate . protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent)); + /// + /// Generic 401 response. + /// + /// An with . + protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null); + /// /// Generic 501 response. /// @@ -264,27 +244,19 @@ namespace Tgstation.Server.Host.Controllers /// /// Response for missing/Invalid headers. /// - /// Whether or not errors due to missing should be thrown. + /// The that occurred while trying to parse the . /// The appropriate . - protected IActionResult HeadersIssue(bool ignoreMissingAuth) + protected IActionResult HeadersIssue(HeadersException headersException) { - HeadersException headersException; - try - { - _ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth); + if (headersException == null) throw new InvalidOperationException("Expected a header parse exception!"); - } - catch (HeadersException ex) - { - headersException = ex; - } var errorMessage = new ErrorMessageResponse(ErrorCode.BadHeaders) { AdditionalData = headersException.Message, }; - if (headersException.MissingOrMalformedHeaders.HasFlag(HeaderTypes.Accept)) + if (headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept)) return this.StatusCode(HttpStatusCode.NotAcceptable, errorMessage); return BadRequest(errorMessage); diff --git a/src/Tgstation.Server.Host/Controllers/ApiControllerBase.cs b/src/Tgstation.Server.Host/Controllers/ApiControllerBase.cs new file mode 100644 index 0000000000..9a8b119808 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/ApiControllerBase.cs @@ -0,0 +1,48 @@ +using System; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// Base class for all API style controllers. + /// + [Produces(MediaTypeNames.Application.Json)] + [ApiController] + public abstract class ApiControllerBase : Controller + { + /// + public sealed override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // never cache an API response + Response.Headers.Add(HeaderNames.CacheControl, new StringValues("no-cache")); + + var errorCase = await HookExecuteAction( + () => base.OnActionExecutionAsync(context, next), + Request.HttpContext.RequestAborted); + + if (errorCase != null) + await errorCase.ExecuteResultAsync(context); + } + + /// + /// Hook for executing a request. + /// + /// A that should be invoked and its response awaited to continue normal execution of the request. Should NOT be called if this method returns a non- value. + /// The for the operation. + /// A resulting in an that, if not , is executed. + protected virtual async ValueTask HookExecuteAction(Func executeAction, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(executeAction); + + await executeAction(); + return null; + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/BridgeController.cs b/src/Tgstation.Server.Host/Controllers/BridgeController.cs index 19c2794f04..53e64e08dc 100644 --- a/src/Tgstation.Server.Host/Controllers/BridgeController.cs +++ b/src/Tgstation.Server.Host/Controllers/BridgeController.cs @@ -4,6 +4,7 @@ using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -20,10 +21,8 @@ namespace Tgstation.Server.Host.Controllers /// for receiving DMAPI requests from DreamDaemon. /// [Route("/Bridge")] - [Produces(MediaTypeNames.Application.Json)] - [ApiController] [ApiExplorerSettings(IgnoreApi = true)] - public class BridgeController : Controller + public sealed class BridgeController : ApiControllerBase { /// /// If the content of bridge requests and responses should be logged. @@ -76,6 +75,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the operation. /// A resulting in the for the operation. [HttpGet] + [AllowAnonymous] public async ValueTask Process([FromQuery] string data, CancellationToken cancellationToken) { // Nothing to see here diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index c1365b1f84..7f9682c43a 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -19,11 +19,12 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; - +using Tgstation.Server.Host.Utils; using Z.EntityFramework.Plus; namespace Tgstation.Server.Host.Controllers @@ -39,19 +40,22 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . + /// The for the . public ChatController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, - IInstanceManager instanceManager) + IInstanceManager instanceManager, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { } diff --git a/src/Tgstation.Server.Host/Controllers/ComponentInterfacingController.cs b/src/Tgstation.Server.Host/Controllers/ComponentInterfacingController.cs index 45e3829bbb..eef1af8b18 100644 --- a/src/Tgstation.Server.Host/Controllers/ComponentInterfacingController.cs +++ b/src/Tgstation.Server.Host/Controllers/ComponentInterfacingController.cs @@ -40,18 +40,21 @@ namespace Tgstation.Server.Host.Controllers /// /// The value of . /// The for the . - /// The for the . + /// The for the . /// The for the . + /// The for the . /// The value of . protected ComponentInterfacingController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, - bool useInstanceRequestHeader = false) + IApiHeadersProvider apiHeaders, + bool useInstanceRequestHeader) : base( databaseContext, - authenticationContextFactory, + authenticationContext, + apiHeaders, logger, true) { diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index b71703af41..37b7e89ed1 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -12,10 +12,12 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -34,21 +36,24 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . + /// The for the . public ConfigurationController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, - IIOManager ioManager) + IIOManager ioManager, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); } @@ -98,10 +103,6 @@ namespace Tgstation.Server.Host.Controllers AdditionalData = e.Message, }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } } /// @@ -145,10 +146,6 @@ namespace Tgstation.Server.Host.Controllers AdditionalData = e.Message, }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } } /// @@ -191,11 +188,6 @@ namespace Tgstation.Server.Host.Controllers return new PaginatableResult(result); } - catch (NotImplementedException ex) - { - return new PaginatableResult( - RequiresPosixSystemIdentity(ex)); - } catch (UnauthorizedAccessException) { return new PaginatableResult( @@ -282,10 +274,6 @@ namespace Tgstation.Server.Host.Controllers Message = e.Message, }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } catch (UnauthorizedAccessException) { return Forbid(); @@ -330,10 +318,6 @@ namespace Tgstation.Server.Host.Controllers : Conflict(new ErrorMessageResponse(ErrorCode.ConfigurationDirectoryNotEmpty)); }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } catch (UnauthorizedAccessException) { return Forbid(); diff --git a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs index c1f35f87e5..08d8f74dad 100644 --- a/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs +++ b/src/Tgstation.Server.Host/Controllers/ControlPanelController.cs @@ -123,7 +123,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The value of the route. /// The to use. - [Route("{**appRoute}")] + [Route("/{**appRoute}")] [HttpGet] public IActionResult Get([FromRoute] string appRoute) { diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index ba6e1d2caf..ea39745845 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -47,23 +47,26 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . /// The value of . + /// The for the . public DreamDaemonController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, IJobManager jobManager, - IPortAllocator portAllocator) + IPortAllocator portAllocator, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); @@ -84,14 +87,7 @@ namespace Tgstation.Server.Host.Controllers if (instance.Watchdog.Status != WatchdogStatus.Offline) return Conflict(new ErrorMessageResponse(ErrorCode.WatchdogRunning)); - var job = new Job - { - Description = "Launch Watchdog", - CancelRight = (ulong)DreamDaemonRights.Shutdown, - CancelRightsType = RightsType.DreamDaemon, - Instance = Instance, - StartedBy = AuthenticationContext.User, - }; + var job = Job.Create(JobCode.WatchdogLaunch, AuthenticationContext.User, Instance, DreamDaemonRights.Shutdown); await jobManager.RegisterOperation( job, (core, databaseContextFactory, paramJob, progressHandler, innerCt) => core.Watchdog.Launch(innerCt), @@ -267,14 +263,7 @@ namespace Tgstation.Server.Host.Controllers public ValueTask Restart(CancellationToken cancellationToken) => WithComponentInstance(async instance => { - var job = new Job - { - Instance = Instance, - CancelRightsType = RightsType.DreamDaemon, - CancelRight = (ulong)DreamDaemonRights.Shutdown, - StartedBy = AuthenticationContext.User, - Description = "Restart Watchdog", - }; + var job = Job.Create(JobCode.WatchdogRestart, AuthenticationContext.User, Instance, DreamDaemonRights.Shutdown); var watchdog = instance.Watchdog; @@ -300,14 +289,7 @@ namespace Tgstation.Server.Host.Controllers public ValueTask CreateDump(CancellationToken cancellationToken) => WithComponentInstance(async instance => { - var job = new Job - { - Instance = Instance, - CancelRightsType = RightsType.DreamDaemon, - CancelRight = (ulong)DreamDaemonRights.CreateDump, - StartedBy = AuthenticationContext.User, - Description = "Create DreamDaemon Process Dump", - }; + var job = Job.Create(JobCode.WatchdogDump, AuthenticationContext.User, Instance, DreamDaemonRights.CreateDump); var watchdog = instance.Watchdog; diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 3c4431a3e1..714713763f 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -13,6 +13,7 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; @@ -43,23 +44,26 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . /// The value of . + /// The for the . public DreamMakerController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, IJobManager jobManager, - IPortAllocator portAllocator) + IPortAllocator portAllocator, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); @@ -139,14 +143,7 @@ namespace Tgstation.Server.Host.Controllers [ProducesResponseType(typeof(JobResponse), 202)] public async ValueTask Create(CancellationToken cancellationToken) { - var job = new Job - { - Description = "Compile active repository code", - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.DreamMaker, - CancelRight = (ulong)DreamMakerRights.CancelCompile, - Instance = Instance, - }; + var job = Job.Create(JobCode.Deployment, AuthenticationContext.User, Instance, DreamMakerRights.CancelCompile); await jobManager.RegisterOperation( job, @@ -257,6 +254,8 @@ namespace Tgstation.Server.Host.Controllers .AsQueryable() .Include(x => x.Job) .ThenInclude(x => x.StartedBy) + .Include(x => x.Job) + .ThenInclude(x => x.Instance) .Include(x => x.RevisionInformation) .ThenInclude(x => x.PrimaryTestMerge) .ThenInclude(x => x.MergedBy) diff --git a/src/Tgstation.Server.Host/Controllers/EngineController.cs b/src/Tgstation.Server.Host/Controllers/EngineController.cs index 2dcf3b6497..75c7ef289b 100644 --- a/src/Tgstation.Server.Host/Controllers/EngineController.cs +++ b/src/Tgstation.Server.Host/Controllers/EngineController.cs @@ -14,12 +14,14 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Transfer; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -50,23 +52,26 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . /// The value of . + /// The for the . public EngineController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, IJobManager jobManager, - IFileTransferTicketProvider fileTransferService) + IFileTransferTicketProvider fileTransferService, + IApiHeadersProvider apiHeadersProvider) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeadersProvider) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService)); @@ -203,14 +208,14 @@ namespace Tgstation.Server.Host.Controllers Instance.Id); // run the install through the job manager - var job = new Models.Job - { - Description = $"Install {(!uploadingZip ? String.Empty : "custom ")}{model.EngineVersion.Engine.Value} version {model.EngineVersion.Version}", - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Engine, - CancelRight = (ulong)EngineRights.CancelInstall, - Instance = Instance, - }; + var job = Models.Job.Create( + uploadingZip + ? JobCode.EngineCustomInstall + : JobCode.EngineOfficialInstall, + AuthenticationContext.User, + Instance, + EngineRights.CancelInstall); + job.Description += $" {model.EngineVersion}"; IFileUploadTicket fileUploadTicket = null; if (uploadingZip) @@ -309,19 +314,16 @@ namespace Tgstation.Server.Host.Controllers var isByondVersion = model.EngineVersion.Engine.Value == EngineType.Byond; // run the install through the job manager - var job = new Models.Job - { - Description = $"Delete installed engine version {model.EngineVersion}", - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Engine, - CancelRight = (ulong)( - isByondVersion - ? model.EngineVersion.Version.Build != -1 - ? EngineRights.InstallOfficialOrChangeActiveByondVersion - : EngineRights.InstallCustomByondVersion - : EngineRights.InstallCustomOpenDreamVersion | EngineRights.InstallOfficialOrChangeActiveOpenDreamVersion), - Instance = Instance, - }; + var cancelRight = isByondVersion + ? model.EngineVersion.CustomIteration.HasValue + ? EngineRights.InstallCustomByondVersion + : EngineRights.InstallOfficialOrChangeActiveByondVersion + : model.EngineVersion.CustomIteration.HasValue + ? EngineRights.InstallOfficialOrChangeActiveOpenDreamVersion + : EngineRights.InstallCustomOpenDreamVersion; + + var job = Models.Job.Create(JobCode.EngineDelete, AuthenticationContext.User, Instance, cancelRight); + job.Description += $" {model.EngineVersion}"; await jobManager.RegisterOperation( job, diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 8aa6d3d03b..659a379591 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,17 +1,16 @@ using System; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; + using Octokit; using Tgstation.Server.Api; @@ -21,12 +20,12 @@ using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; -using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Security.OAuth; using Tgstation.Server.Host.Swarm; using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -95,7 +94,7 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The value of . /// The value of . /// The value of . @@ -108,9 +107,10 @@ namespace Tgstation.Server.Host.Controllers /// The containing the value of . /// The containing the value of . /// The for the . + /// The for the . public HomeController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ITokenFactory tokenFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, @@ -122,10 +122,12 @@ namespace Tgstation.Server.Host.Controllers IServerControl serverControl, IOptions generalConfigurationOptions, IOptions controlPanelConfigurationOptions, - ILogger logger) + ILogger logger, + IApiHeadersProvider apiHeadersProvider) : base( databaseContext, - authenticationContextFactory, + authenticationContext, + apiHeadersProvider, logger, false) { @@ -160,6 +162,8 @@ namespace Tgstation.Server.Host.Controllers HeaderNames.Vary, new StringValues(ApiHeaders.ApiVersionHeader)); + // if they tried to authenticate in any form and failed, let them know immediately + bool failIfUnauthed; if (ApiHeaders == null) { if (controlPanelConfiguration.Enable && !Request.Headers.TryGetValue(ApiHeaders.ApiVersionHeader, out _)) @@ -176,17 +180,20 @@ namespace Tgstation.Server.Host.Controllers try { // we only allow authorization header issues - var headers = new ApiHeaders(Request.GetTypedHeaders(), true); - if (!headers.Compatible()) - return this.StatusCode( - HttpStatusCode.UpgradeRequired, - new ErrorMessageResponse(ErrorCode.ApiMismatch)); + ApiHeadersProvider.CreateAuthlessHeaders(); } - catch (HeadersException) + catch (HeadersException ex) { - return HeadersIssue(true); + return HeadersIssue(ex); } + + failIfUnauthed = Request.Headers.Authorization.Any(); } + else + failIfUnauthed = ApiHeaders.Token != null; + + if (failIfUnauthed && !AuthenticationContext.Valid) + return Unauthorized(); return Json(new ServerInformationResponse { @@ -223,8 +230,8 @@ namespace Tgstation.Server.Host.Controllers { if (ApiHeaders == null) { - Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues("basic realm=\"Create TGS bearer token\"")); - return HeadersIssue(false); + Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\"")); + return HeadersIssue(ApiHeadersProvider.HeadersException); } if (ApiHeaders.IsTokenAuthentication) @@ -239,9 +246,9 @@ namespace Tgstation.Server.Host.Controllers // trust the system over the database because a user's name can change while still having the same SID systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password, cancellationToken); } - catch (NotImplementedException ex) + catch (NotImplementedException) { - RequiresPosixSystemIdentity(ex); + // Intentionally suppressed } using (systemIdentity) @@ -261,7 +268,7 @@ namespace Tgstation.Server.Host.Controllers return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled)); externalUserId = await validator - .ValidateResponseCode(ApiHeaders.Token, cancellationToken); + .ValidateResponseCode(ApiHeaders.OAuthCode!, cancellationToken); Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); } @@ -369,11 +376,11 @@ namespace Tgstation.Server.Host.Controllers return Forbid(); } - var token = await tokenFactory.CreateToken(user, oAuthLogin, cancellationToken); + var token = tokenFactory.CreateToken(user, oAuthLogin); if (usingSystemIdentity) { // expire the identity slightly after the auth token in case of lag - var identExpiry = token.ExpiresAt; + var identExpiry = token.ParseJwt().ValidTo; identExpiry += tokenFactory.ValidationParameters.ClockSkew; identExpiry += TimeSpan.FromSeconds(15); identityCache.CacheSystemIdentity(user, systemIdentity, identExpiry); diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 494b27db2b..d63396daf9 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -19,6 +19,7 @@ using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.IO; @@ -67,6 +68,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IPortAllocator portAllocator; + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + /// /// The for the . /// @@ -81,36 +87,44 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . /// The value of . /// The value of . - /// The value of . + /// The value of . + /// The value of . /// The containing the value of . /// The containing the value of . + /// The for the . public InstanceController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, IJobManager jobManager, IIOManager ioManager, IPortAllocator portAllocator, IPlatformIdentifier platformIdentifier, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, IOptions generalConfigurationOptions, - IOptions swarmConfigurationOptions) + IOptions swarmConfigurationOptions, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders, + false) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); + this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); + generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); } @@ -262,6 +276,10 @@ namespace Tgstation.Server.Host.Controllers newInstance.Id, newInstance.Path); + await permissionsUpdateNotifyee.InstancePermissionSetCreated( + newInstance.InstancePermissionSets.First(), + cancellationToken); + var api = newInstance.ToApi(); api.Accessible = true; // instances are always accessible by their creator return attached ? Json(api) : Created(api); @@ -337,10 +355,7 @@ namespace Tgstation.Server.Host.Controllers var moveJob = await InstanceQuery() .SelectMany(x => x.Jobs). Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix)) - .Select(x => new Job - { - Id = x.Id, - }).FirstOrDefaultAsync(cancellationToken); + .Select(x => new Job(x.Id.Value)).FirstOrDefaultAsync(cancellationToken); if (moveJob != default) { @@ -472,14 +487,9 @@ namespace Tgstation.Server.Host.Controllers var moving = originalModelPath != null; if (moving) { - var job = new Job - { - Description = $"{MoveInstanceJobPrefix}{originalModel.Id} from {originalModelPath} to {rawPath}", - Instance = originalModel, - CancelRightsType = RightsType.InstanceManager, - CancelRight = (ulong)InstanceManagerRights.Relocate, - StartedBy = AuthenticationContext.User, - }; + var description = $"{MoveInstanceJobPrefix}{originalModel.Id} from {originalModelPath} to {rawPath}"; + var job = Job.Create(JobCode.Move, AuthenticationContext.User, originalModel, InstanceManagerRights.Relocate); + job.Description = description; await jobManager.RegisterOperation( job, @@ -622,7 +632,9 @@ namespace Tgstation.Server.Host.Controllers var moveJob = await QueryForUser() .SelectMany(x => x.Jobs) .Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix)) - .Include(x => x.StartedBy).ThenInclude(x => x.CreatedBy) + .Include(x => x.StartedBy) + .ThenInclude(x => x.CreatedBy) + .Include(x => x.Instance) .FirstOrDefaultAsync(cancellationToken); api.MoveJob = moveJob?.ToApi(); await CheckAccessible(api, cancellationToken); @@ -763,6 +775,7 @@ namespace Tgstation.Server.Host.Controllers { permissionSetToModify ??= new InstancePermissionSet() { + PermissionSet = AuthenticationContext.PermissionSet, PermissionSetId = AuthenticationContext.PermissionSet.Id.Value, }; permissionSetToModify.EngineRights = RightsHelper.AllRights(); diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index f25bcea52d..13defd8048 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -13,10 +13,12 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; using Z.EntityFramework.Plus; @@ -28,24 +30,35 @@ namespace Tgstation.Server.Host.Controllers [Route(Routes.InstancePermissionSet)] public sealed class InstancePermissionSetController : InstanceRequiredController { + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + /// /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . + /// The value of . + /// The for the . public InstancePermissionSetController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, - IInstanceManager instanceManager) + IInstanceManager instanceManager, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { + this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); } /// @@ -71,6 +84,7 @@ namespace Tgstation.Server.Host.Controllers .Where(x => x.Id == model.PermissionSetId) .Select(x => new Models.PermissionSet { + Id = x.Id, UserId = x.UserId, }) .FirstOrDefaultAsync(cancellationToken); @@ -93,9 +107,7 @@ namespace Tgstation.Server.Host.Controllers var dbUser = new InstancePermissionSet { -#pragma warning disable CS0618 // Type or member is obsolete - EngineRights = RightsHelper.Clamp(model.EngineRights ?? model.ByondRights ?? EngineRights.None), -#pragma warning restore CS0618 // Type or member is obsolete + EngineRights = RightsHelper.Clamp(model.EngineRights ?? EngineRights.None), ChatBotRights = RightsHelper.Clamp(model.ChatBotRights ?? ChatBotRights.None), ConfigurationRights = RightsHelper.Clamp(model.ConfigurationRights ?? ConfigurationRights.None), DreamDaemonRights = RightsHelper.Clamp(model.DreamDaemonRights ?? DreamDaemonRights.None), @@ -109,6 +121,10 @@ namespace Tgstation.Server.Host.Controllers DatabaseContext.InstancePermissionSets.Add(dbUser); await DatabaseContext.Save(cancellationToken); + + // needs to be set for next call + dbUser.PermissionSet = existingPermissionSet; + await permissionsUpdateNotifyee.InstancePermissionSetCreated(dbUser, cancellationToken); return Created(dbUser.ToApi()); } #pragma warning restore CA1506 @@ -140,9 +156,7 @@ namespace Tgstation.Server.Host.Controllers if (originalPermissionSet == null) return this.Gone(); -#pragma warning disable CS0618 // Type or member is obsolete - originalPermissionSet.EngineRights = RightsHelper.Clamp(model.EngineRights ?? model.ByondRights ?? originalPermissionSet.EngineRights.Value); -#pragma warning restore CS0618 // Type or member is obsolete + originalPermissionSet.EngineRights = RightsHelper.Clamp(model.EngineRights ?? originalPermissionSet.EngineRights.Value); originalPermissionSet.RepositoryRights = RightsHelper.Clamp(model.RepositoryRights ?? originalPermissionSet.RepositoryRights.Value); originalPermissionSet.InstancePermissionSetRights = RightsHelper.Clamp(model.InstancePermissionSetRights ?? originalPermissionSet.InstancePermissionSetRights.Value); originalPermissionSet.ChatBotRights = RightsHelper.Clamp(model.ChatBotRights ?? originalPermissionSet.ChatBotRights.Value); diff --git a/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs b/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs index 1f119500f5..cdf3f0e139 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceRequiredController.cs @@ -3,6 +3,7 @@ using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -15,19 +16,22 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . + /// The for the . protected InstanceRequiredController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, - IInstanceManager instanceManager) + IInstanceManager instanceManager, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, instanceManager, + apiHeaders, true) { } diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 84f4cc5084..521a560a0b 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -11,11 +11,13 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -34,21 +36,24 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . + /// The for the . public JobController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, - IJobManager jobManager) + IJobManager jobManager, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); } @@ -73,6 +78,7 @@ namespace Tgstation.Server.Host.Controllers .AsQueryable() .Include(x => x.StartedBy) .Include(x => x.CancelledBy) + .Include(x => x.Instance) .Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue) .OrderByDescending(x => x.StartedAt))), AddJobProgressResponseTransformer, @@ -100,6 +106,7 @@ namespace Tgstation.Server.Host.Controllers .AsQueryable() .Include(x => x.StartedBy) .Include(x => x.CancelledBy) + .Include(x => x.Instance) .Where(x => x.Instance.Id == Instance.Id) .OrderByDescending(x => x.StartedAt))), AddJobProgressResponseTransformer, @@ -127,6 +134,7 @@ namespace Tgstation.Server.Host.Controllers .Jobs .AsQueryable() .Include(x => x.StartedBy) + .Include(x => x.Instance) .Where(x => x.Id == id && x.Instance.Id == Instance.Id) .FirstOrDefaultAsync(cancellationToken); if (job == default) @@ -162,6 +170,7 @@ namespace Tgstation.Server.Host.Controllers .Where(x => x.Id == id && x.Instance.Id == Instance.Id) .Include(x => x.StartedBy) .Include(x => x.CancelledBy) + .Include(x => x.Instance) .FirstOrDefaultAsync(cancellationToken); if (job == default) return NotFound(); diff --git a/src/Tgstation.Server.Host/Controllers/README.md b/src/Tgstation.Server.Host/Controllers/README.md index fa7f1c5a1f..e1214f91f2 100644 --- a/src/Tgstation.Server.Host/Controllers/README.md +++ b/src/Tgstation.Server.Host/Controllers/README.md @@ -12,4 +12,3 @@ Some notable exceptions: - Returns 401 If an `IAuthenticationContext` could not be created for a request. - [BridgeController](./BridgeController.cs) is a special controller accessible only from localhost and is used to receive bridge request from DreamDaemon - [HomeController](./HomeController.cs) contains the code to initially log in and generate an API token for a user. -- [TgsAuthorizeAttribute](./TgsAuthorizeAttribute.cs) is a special attribute applied to controller methods to define which rights are required to run a verb. diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 0b02d7d0c1..37766ce0fd 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -22,6 +22,7 @@ using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -46,23 +47,26 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The for the . /// The for the . /// The value of . /// The value of . + /// The for the . public RepositoryController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, ILoggerFactory loggerFactory, - IJobManager jobManager) + IJobManager jobManager, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, logger, - instanceManager) + instanceManager, + apiHeaders) { this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); @@ -135,20 +139,15 @@ namespace Tgstation.Server.Host.Controllers if (repo != null) return Conflict(new ErrorMessageResponse(ErrorCode.RepoExists)); - var job = new Job - { - Description = String.Format( + var description = String.Format( CultureInfo.InvariantCulture, "Clone{1} repository {0}", origin, cloneBranch != null ? $"\"{cloneBranch}\" branch of" - : String.Empty), - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Repository, - CancelRight = (ulong)RepositoryRights.CancelClone, - Instance = Instance, - }; + : String.Empty); + var job = Job.Create(JobCode.RepositoryClone, AuthenticationContext.User, Instance, RepositoryRights.CancelClone); + job.Description = description; var api = currentModel.ToApi(); await DatabaseContext.Save(cancellationToken); @@ -218,12 +217,7 @@ namespace Tgstation.Server.Host.Controllers Logger.LogInformation("Instance {instanceId} repository delete initiated by user {userId}", Instance.Id, AuthenticationContext.User.Id.Value); - var job = new Job - { - Description = "Delete repository", - StartedBy = AuthenticationContext.User, - Instance = Instance, - }; + var job = Job.Create(JobCode.RepositoryDelete, AuthenticationContext.User, Instance); var api = currentModel.ToApi(); await jobManager.RegisterOperation( job, @@ -450,14 +444,8 @@ namespace Tgstation.Server.Host.Controllers if (description == null) return Json(api); // no git changes - var job = new Job - { - Description = description, - StartedBy = AuthenticationContext.User, - Instance = Instance, - CancelRightsType = RightsType.Repository, - CancelRight = (ulong)RepositoryRights.CancelPendingChanges, - }; + var job = Job.Create(JobCode.RepositoryUpdate, AuthenticationContext.User, Instance, RepositoryRights.CancelPendingChanges); + job.Description = description; var repositoryUpdater = new RepositoryUpdateService( model, diff --git a/src/Tgstation.Server.Host/Controllers/LimitedStreamResult.cs b/src/Tgstation.Server.Host/Controllers/Results/LimitedStreamResult.cs similarity index 96% rename from src/Tgstation.Server.Host/Controllers/LimitedStreamResult.cs rename to src/Tgstation.Server.Host/Controllers/Results/LimitedStreamResult.cs index 734a1731a6..998f5779c5 100644 --- a/src/Tgstation.Server.Host/Controllers/LimitedStreamResult.cs +++ b/src/Tgstation.Server.Host/Controllers/Results/LimitedStreamResult.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Tgstation.Server.Host.IO; -namespace Tgstation.Server.Host.Controllers +namespace Tgstation.Server.Host.Controllers.Results { /// /// Very similar to except it's contains a fix for https://github.com/dotnet/aspnetcore/issues/28189. diff --git a/src/Tgstation.Server.Host/Controllers/LimitedStreamResultExecutor.cs b/src/Tgstation.Server.Host/Controllers/Results/LimitedStreamResultExecutor.cs similarity index 97% rename from src/Tgstation.Server.Host/Controllers/LimitedStreamResultExecutor.cs rename to src/Tgstation.Server.Host/Controllers/Results/LimitedStreamResultExecutor.cs index 7a8169c1d3..3178db355b 100644 --- a/src/Tgstation.Server.Host/Controllers/LimitedStreamResultExecutor.cs +++ b/src/Tgstation.Server.Host/Controllers/Results/LimitedStreamResultExecutor.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Logging; -namespace Tgstation.Server.Host.Controllers +namespace Tgstation.Server.Host.Controllers.Results { /// /// for s. diff --git a/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs b/src/Tgstation.Server.Host/Controllers/Results/PaginatableResult.cs similarity index 96% rename from src/Tgstation.Server.Host/Controllers/PaginatableResult.cs rename to src/Tgstation.Server.Host/Controllers/Results/PaginatableResult.cs index ae19ba913e..b936d0f3e6 100644 --- a/src/Tgstation.Server.Host/Controllers/PaginatableResult.cs +++ b/src/Tgstation.Server.Host/Controllers/Results/PaginatableResult.cs @@ -3,7 +3,7 @@ using System.Linq; using Microsoft.AspNetCore.Mvc; -namespace Tgstation.Server.Host.Controllers +namespace Tgstation.Server.Host.Controllers.Results { /// /// Helper for returning paginated models. diff --git a/src/Tgstation.Server.Host/Controllers/SwarmController.cs b/src/Tgstation.Server.Host/Controllers/SwarmController.cs index 4829854ac5..2512eda8dd 100644 --- a/src/Tgstation.Server.Host/Controllers/SwarmController.cs +++ b/src/Tgstation.Server.Host/Controllers/SwarmController.cs @@ -5,8 +5,8 @@ using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -25,10 +25,9 @@ namespace Tgstation.Server.Host.Controllers /// For swarm server communication. /// [Route(SwarmConstants.ControllerRoute)] - [Produces(MediaTypeNames.Application.Json)] - [ApiController] [ApiExplorerSettings(IgnoreApi = true)] - public sealed class SwarmController : Controller + [AllowAnonymous] // We have custom private key auth + public sealed class SwarmController : ApiControllerBase { /// /// Get the current registration from the . @@ -211,7 +210,7 @@ namespace Tgstation.Server.Host.Controllers } /// - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + protected override async ValueTask HookExecuteAction(Func executeAction, CancellationToken cancellationToken) { using (LogContext.PushProperty(SerilogContextHelper.RequestPathContextProperty, $"{Request.Method} {Request.Path}")) { @@ -219,8 +218,7 @@ namespace Tgstation.Server.Host.Controllers if (swarmConfiguration.PrivateKey == null) { logger.LogDebug("Attempted swarm request without private key configured!"); - await Forbid().ExecuteResultAsync(context); - return; + return Forbid(); } if (!(Request.Headers.TryGetValue(SwarmConstants.ApiKeyHeader, out var apiKeyHeaderValues) @@ -228,8 +226,7 @@ namespace Tgstation.Server.Host.Controllers && apiKeyHeaderValues.First() == swarmConfiguration.PrivateKey)) { logger.LogDebug("Unauthorized swarm request!"); - await Unauthorized().ExecuteResultAsync(context); - return; + return Unauthorized(); } if (!(Request.Headers.TryGetValue(SwarmConstants.RegistrationIdHeader, out var registrationHeaderValues) @@ -237,8 +234,7 @@ namespace Tgstation.Server.Host.Controllers && Guid.TryParse(registrationHeaderValues.First(), out var registrationId))) { logger.LogDebug("Swarm request without registration ID!"); - await BadRequest().ExecuteResultAsync(context); - return; + return BadRequest(); } // we validate the registration itself on a case-by-case basis @@ -252,12 +248,12 @@ namespace Tgstation.Server.Host.Controllers "Swarm request model validation failed!{newLine}{messages}", Environment.NewLine, String.Join(Environment.NewLine, errorMessages)); - await BadRequest().ExecuteResultAsync(context); - return; + return BadRequest(); } logger.LogTrace("Starting swarm request processing..."); - await base.OnActionExecutionAsync(context, next); + await executeAction(); + return null; } } diff --git a/src/Tgstation.Server.Host/Controllers/TransferController.cs b/src/Tgstation.Server.Host/Controllers/TransferController.cs index be12cfda91..8a0a2ebcd3 100644 --- a/src/Tgstation.Server.Host/Controllers/TransferController.cs +++ b/src/Tgstation.Server.Host/Controllers/TransferController.cs @@ -9,10 +9,12 @@ using Microsoft.Extensions.Logging; using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Security; using Tgstation.Server.Host.Transfer; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -32,17 +34,20 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The value of . /// The for the . + /// The for the . public TransferController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, IFileTransferStreamHandler fileTransferService, - ILogger logger) + ILogger logger, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, + apiHeaders, logger, true) { diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 04559bde3c..08c61e05fe 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -15,10 +15,12 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Controllers { @@ -38,6 +40,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly ICryptographySuite cryptographySuite; + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + /// /// The for the . /// @@ -47,26 +54,32 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The value of . /// The value of . + /// The value of . /// The for the . /// The containing the value of . + /// The for the . public UserController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, ILogger logger, - IOptions generalConfigurationOptions) + IOptions generalConfigurationOptions, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, + apiHeaders, logger, true) { this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory)); this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -238,13 +251,17 @@ namespace Tgstation.Server.Host.Controllers if (model.Name != null && Models.User.CanonicalizeName(model.Name) != originalUser.CanonicalName) return BadRequest(new ErrorMessageResponse(ErrorCode.UserNameChange)); + bool userWasDisabled; if (model.Enabled.HasValue) { - if (originalUser.Enabled.Value && !model.Enabled.Value) + userWasDisabled = originalUser.Enabled.Value && !model.Enabled.Value; + if (userWasDisabled) originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow; originalUser.Enabled = model.Enabled.Value; } + else + userWasDisabled = false; if (model.OAuthConnections != null && (model.OAuthConnections.Count != originalUser.OAuthConnections.Count @@ -280,7 +297,7 @@ namespace Tgstation.Server.Host.Controllers DatabaseContext.Groups.Attach(originalUser.Group); if (originalUser.PermissionSet != null) { - Logger.LogInformation("Deleting permission set {0}...", originalUser.PermissionSet.Id); + Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id); DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet); originalUser.PermissionSet = null; } @@ -308,7 +325,10 @@ namespace Tgstation.Server.Host.Controllers await DatabaseContext.Save(cancellationToken); - Logger.LogInformation("Updated user {0} ({1})", originalUser.Name, originalUser.Id); + Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id); + + if (userWasDisabled) + await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken); // return id only if not a self update and cannot read users var canReadBack = AuthenticationContext.User.Id == originalUser.Id diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 14269e3254..3f2c5ea3a2 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -14,10 +14,12 @@ using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; using Z.EntityFramework.Plus; @@ -38,19 +40,22 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The for the . - /// The for the . + /// The for the . /// The containing the value of . /// The for the . + /// The for the . public UserGroupController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, IOptions generalConfigurationOptions, - ILogger logger) + ILogger logger, + IApiHeadersProvider apiHeaders) : base( - databaseContext, - authenticationContextFactory, - logger, - true) + databaseContext, + authenticationContext, + apiHeaders, + logger, + true) { generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -93,7 +98,7 @@ namespace Tgstation.Server.Host.Controllers DatabaseContext.Groups.Add(dbGroup); await DatabaseContext.Save(cancellationToken); - Logger.LogInformation("Created new user group {0} ({1})", dbGroup.Name, dbGroup.Id); + Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id); return Created(dbGroup.ToApi(true)); } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 573321d7ac..795ffe54ce 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -2,17 +2,23 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading.Tasks; using Cyberboss.AspNetCore.AsyncInitializer; using Elastic.CommonSchema.Serilog; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -27,6 +33,7 @@ using Serilog.Formatting.Display; using Serilog.Sinks.Elasticsearch; using Tgstation.Server.Api; +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models; using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components; @@ -41,6 +48,7 @@ using Tgstation.Server.Host.Components.Session; using Tgstation.Server.Host.Components.Watchdog; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Extensions.Converters; @@ -115,7 +123,7 @@ namespace Tgstation.Server.Host.Core } /// - /// Configure the 's services. + /// Configure the 's . /// /// The to configure. /// The needed for configuration. @@ -217,50 +225,48 @@ namespace Tgstation.Server.Host.Core postSetupServices.InternalConfiguration, postSetupServices.FileLoggingConfiguration); - // configure bearer token validation - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(jwtBearerOptions => - { - // this line isn't actually run until the first request is made - // at that point tokenFactory will be populated - jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters; - jwtBearerOptions.MapInboundClaims = false; - jwtBearerOptions.Events = new JwtBearerEvents - { - // Application is our composition root so this monstrosity of a line is okay - // At least, that's what I tell myself to sleep at night - OnTokenValidated = ctx => ctx - .HttpContext - .RequestServices - .GetRequiredService() - .InjectClaimsIntoContext( - ctx, - ctx.HttpContext.RequestAborted), - }; - }); + // configure authentication pipeline + ConfigureAuthenticationPipeline(services); // add mvc, configure the json serializer settings + var jsonVersionConverterList = new List + { + new VersionConverter(), + }; + + void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings settings) + { + settings.NullValueHandling = NullValueHandling.Ignore; + settings.CheckAdditionalContent = true; + settings.MissingMemberHandling = MissingMemberHandling.Error; + settings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; + settings.Converters = jsonVersionConverterList; + } + services .AddMvc(options => { - options.EnableEndpointRouting = false; options.ReturnHttpNotAcceptable = true; options.RespectBrowserAcceptHeader = true; }) .AddNewtonsoftJson(options => { options.AllowInputFormatterExceptionMessages = true; - options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; - options.SerializerSettings.CheckAdditionalContent = true; - options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error; - options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; - options.SerializerSettings.Converters = new List - { - new VersionConverter(), - }; + ConfigureNewtonsoftJsonSerializerSettingsForApi(options.SerializerSettings); }); + services.AddSignalR( + options => + { + options.AddFilter(); + }) + .AddNewtonsoftJsonProtocol(options => + { + ConfigureNewtonsoftJsonSerializerSettingsForApi(options.PayloadSerializerSettings); + }); + + services.AddHub(); + if (postSetupServices.GeneralConfiguration.HostApiDocumentation) { string GetDocumentationFilePath(string assemblyLocation) => ioManager.ConcatPath(ioManager.GetDirectoryName(assemblyLocation), String.Concat(ioManager.GetFileNameWithoutExtension(assemblyLocation), ".xml")); @@ -319,9 +325,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); - // configure security services - services.AddScoped(); - services.AddScoped(); + // configure other security services services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -422,8 +426,13 @@ namespace Tgstation.Server.Host.Core services.AddGitHub(); // configure root services - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(x => x.GetRequiredService()); // bit of a hack, but we need this to load immediated services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); @@ -494,30 +503,6 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Swagger API generation enabled"); } - // Set up CORS based on configuration if necessary - Action corsBuilder = null; - if (controlPanelConfiguration.AllowAnyOrigin) - { - logger.LogTrace("Access-Control-Allow-Origin: *"); - corsBuilder = builder => builder.AllowAnyOrigin(); - } - else if (controlPanelConfiguration.AllowedOrigins?.Count > 0) - { - logger.LogTrace("Access-Control-Allow-Origin: {allowedOrigins}", String.Join(',', controlPanelConfiguration.AllowedOrigins)); - corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray()); - } - - var originalBuilder = corsBuilder; - corsBuilder = builder => - { - builder - .AllowAnyHeader() - .AllowAnyMethod() - .SetPreflightMaxAge(TimeSpan.FromDays(1)); - originalBuilder?.Invoke(builder); - }; - applicationBuilder.UseCors(corsBuilder); - // spa loading if necessary if (controlPanelConfiguration.Enable) { @@ -531,22 +516,68 @@ namespace Tgstation.Server.Host.Core } else #if NO_WEBPANEL - logger.LogTrace("Web control panel was not included in TGS build!"); + logger.LogDebug("Web control panel was not included in TGS build!"); #else logger.LogTrace("Web control panel disabled!"); #endif - // Do not cache a single thing beyond this point, it's all API - applicationBuilder.UseDisabledClientCache(); + // Enable endpoint routing + applicationBuilder.UseRouting(); + + // Set up CORS based on configuration if necessary + Action corsBuilder = null; + if (controlPanelConfiguration.AllowAnyOrigin) + { + logger.LogTrace("Access-Control-Allow-Origin: *"); + corsBuilder = builder => builder.SetIsOriginAllowed(_ => true); + } + else if (controlPanelConfiguration.AllowedOrigins?.Count > 0) + { + logger.LogTrace("Access-Control-Allow-Origin: {allowedOrigins}", String.Join(',', controlPanelConfiguration.AllowedOrigins)); + corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray()); + } + + var originalBuilder = corsBuilder; + corsBuilder = builder => + { + builder + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + .SetPreflightMaxAge(TimeSpan.FromDays(1)); + originalBuilder?.Invoke(builder); + }; + applicationBuilder.UseCors(corsBuilder); + + // validate the API version + applicationBuilder.UseApiCompatibility(); // authenticate JWT tokens using our security pipeline if present, returns 401 if bad applicationBuilder.UseAuthentication(); + // enable authorization on endpoints + applicationBuilder.UseAuthorization(); + // suppress and log database exceptions applicationBuilder.UseDbConflictHandling(); - // majority of handling is done in the controllers - applicationBuilder.UseMvc(); + // setup endpoints + applicationBuilder.UseEndpoints(endpoints => + { + // access to the signalR jobs hub + endpoints.MapHub( + Routes.JobsHub, + options => + { + options.Transports = HttpTransportType.ServerSentEvents; + options.CloseOnAuthenticationExpiration = true; + }) + .RequireAuthorization() + .RequireCors(corsBuilder); + + // majority of handling is done in the controllers + endpoints.MapControllers(); + }); // 404 anything that gets this far // End of request pipeline setup @@ -561,5 +592,60 @@ namespace Tgstation.Server.Host.Core /// protected override void ConfigureHostedService(IServiceCollection services) => services.AddSingleton(x => x.GetRequiredService()); + + /// + /// Configure the for the authentication pipeline. + /// + /// The to configure. + void ConfigureAuthenticationPipeline(IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + + // what if you + // wanted to just do this: + // return provider.GetRequiredService().CurrentAuthenticationContext + // But M$ said + // https://stackoverflow.com/questions/56792917/scoped-services-in-asp-net-core-with-signalr-hubs + services.AddScoped(provider => provider + .GetRequiredService() + .HttpContext + .RequestServices + .GetRequiredService() + .CurrentAuthenticationContext); + services.AddScoped(); + services.AddScoped(); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(jwtBearerOptions => + { + // this line isn't actually run until the first request is made + // at that point tokenFactory will be populated + jwtBearerOptions.TokenValidationParameters = tokenFactory?.ValidationParameters ?? throw new InvalidOperationException("tokenFactory not initialized!"); + jwtBearerOptions.MapInboundClaims = false; + jwtBearerOptions.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + if (String.IsNullOrWhiteSpace(context.Token)) + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + + if (!String.IsNullOrWhiteSpace(accessToken) && + path.StartsWithSegments(Routes.HubsRoot, StringComparison.OrdinalIgnoreCase)) + { + context.Token = accessToken; + } + } + + return Task.CompletedTask; + }, + }; + }); + } } } diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index 938f34af7e..cca6313b53 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -423,6 +423,15 @@ namespace Tgstation.Server.Host.Database string BadDatabaseType() => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); if (targetVersion < new Version(6, 0, 0)) + targetMigration = currentDatabaseType switch + { + DatabaseType.MySql => nameof(MYAddJobCodes), + DatabaseType.PostgresSql => nameof(PGAddJobCodes), + DatabaseType.SqlServer => nameof(MSAddJobCodes), + DatabaseType.Sqlite => nameof(SLAddJobCodes), + _ => BadDatabaseType(), + }; + if (targetVersion < new Version(5, 17, 0)) targetMigration = currentDatabaseType switch { DatabaseType.MySql => nameof(MYAddMapThreads), @@ -431,7 +440,6 @@ namespace Tgstation.Server.Host.Database DatabaseType.Sqlite => nameof(SLAddMapThreads), _ => BadDatabaseType(), }; - if (targetVersion < new Version(5, 13, 0)) targetMigration = currentDatabaseType switch { diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.Designer.cs new file mode 100644 index 0000000000..556f6d1009 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.Designer.cs @@ -0,0 +1,1075 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqlServerDatabaseContext))] + [Migration("20231105004801_MSAddJobCodes")] + partial class MSAddJobCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChannelLimit") + .HasColumnType("int"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("decimal(20,0)"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique() + .HasFilter("[DiscordChannelId] IS NOT NULL"); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique() + .HasFilter("[IrcChannel] IS NOT NULL"); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uniqueidentifier"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RepositoryOrigin") + .HasColumnType("nvarchar(max)"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("bit"); + + b.Property("HealthCheckSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("bit"); + + b.Property("MapThreads") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("bit"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiValidationPort") + .HasColumnType("int"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("int"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Online") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("SwarmIdentifer") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique() + .HasFilter("[SwarmIdentifer] IS NOT NULL"); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ByondRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("decimal(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("decimal(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("decimal(20,0)"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("decimal(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CancelRight") + .HasColumnType("decimal(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("decimal(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("JobCode") + .HasColumnType("tinyint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AdministrationRights") + .HasColumnType("decimal(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("decimal(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique() + .HasFilter("[GroupId] IS NOT NULL"); + + b.HasIndex("UserId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("InitialCompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("LaunchVisibility") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("bit"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("bit"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("bit"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("bit"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("bit"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("bit"); + + b.Property("UpdateSubmodules") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("MergedAt") + .HasColumnType("datetimeoffset"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetimeoffset"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("bit"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique() + .HasFilter("[SystemIdentifier] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.cs new file mode 100644 index 0000000000..87636a060f --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.cs @@ -0,0 +1,33 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + public partial class MSAddJobCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.AddColumn( + name: "JobCode", + table: "Jobs", + type: "tinyint", + nullable: false, + defaultValue: (byte)0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.DropColumn( + name: "JobCode", + table: "Jobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.Designer.cs new file mode 100644 index 0000000000..c5f5fb94dc --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.Designer.cs @@ -0,0 +1,1109 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(MySqlDatabaseContext))] + [Migration("20231105004808_MYAddJobCodes")] + partial class MYAddJobCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ConnectionString"), "utf8mb4"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("bigint unsigned"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("IrcChannel"), "utf8mb4"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Tag"), "utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ByondVersion"), "utf8mb4"); + + b.Property("DMApiMajorVersion") + .HasColumnType("int"); + + b.Property("DMApiMinorVersion") + .HasColumnType("int"); + + b.Property("DMApiPatchVersion") + .HasColumnType("int"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("DmeName"), "utf8mb4"); + + b.Property("GitHubDeploymentId") + .HasColumnType("int"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("int"); + + b.Property("Output") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Output"), "utf8mb4"); + + b.Property("RepositoryOrigin") + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("RepositoryOrigin"), "utf8mb4"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AdditionalParameters"), "utf8mb4"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("HealthCheckSeconds") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("MapThreads") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("Port") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("SecurityLevel") + .HasColumnType("int"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("Visibility") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ProjectName"), "utf8mb4"); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("time(6)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("int unsigned"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("smallint unsigned"); + + b.Property("ConfigurationType") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Name"), "utf8mb4"); + + b.Property("Online") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("Path") + .IsRequired() + .HasColumnType("varchar(255)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Path"), "utf8mb4"); + + b.Property("SwarmIdentifer") + .HasColumnType("varchar(255)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("SwarmIdentifer"), "utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ByondRights") + .HasColumnType("bigint unsigned"); + + b.Property("ChatBotRights") + .HasColumnType("bigint unsigned"); + + b.Property("ConfigurationRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamDaemonRights") + .HasColumnType("bigint unsigned"); + + b.Property("DreamMakerRights") + .HasColumnType("bigint unsigned"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("bigint unsigned"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("bigint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CancelRight") + .HasColumnType("bigint unsigned"); + + b.Property("CancelRightsType") + .HasColumnType("bigint unsigned"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Description"), "utf8mb4"); + + b.Property("ErrorCode") + .HasColumnType("int unsigned"); + + b.Property("ExceptionDetails") + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ExceptionDetails"), "utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("JobCode") + .HasColumnType("tinyint unsigned"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("ExternalUserId"), "utf8mb4"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AdministrationRights") + .HasColumnType("bigint unsigned"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("bigint unsigned"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AccessIdentifier"), "utf8mb4"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("InitialCompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("int"); + + b.Property("LaunchVisibility") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("smallint unsigned"); + + b.Property("ProcessId") + .HasColumnType("int"); + + b.Property("RebootState") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AccessToken"), "utf8mb4"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("AccessUser"), "utf8mb4"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CommitterEmail"), "utf8mb4"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CommitterName"), "utf8mb4"); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("UpdateSubmodules") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CommitSha"), "utf8mb4"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("OriginCommitSha"), "utf8mb4"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Author") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Author"), "utf8mb4"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("BodyAtMerge"), "utf8mb4"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Comment"), "utf8mb4"); + + b.Property("MergedAt") + .HasColumnType("datetime(6)"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("TargetCommitSha"), "utf8mb4"); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("TitleAtMerge"), "utf8mb4"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Url"), "utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("CanonicalName"), "utf8mb4"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("datetime(6)"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("tinyint(1)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Name"), "utf8mb4"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("PasswordHash"), "utf8mb4"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("SystemIdentifier"), "utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + MySqlPropertyBuilderExtensions.HasCharSet(b.Property("Name"), "utf8mb4"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.cs new file mode 100644 index 0000000000..61eb53b6a5 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.cs @@ -0,0 +1,33 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + public partial class MYAddJobCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.AddColumn( + name: "JobCode", + table: "Jobs", + type: "tinyint unsigned", + nullable: false, + defaultValue: (byte)0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.DropColumn( + name: "JobCode", + table: "Jobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.Designer.cs new file mode 100644 index 0000000000..6ad0149338 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.Designer.cs @@ -0,0 +1,1069 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(PostgresSqlDatabaseContext))] + [Migration("20231105004814_PGAddJobCodes")] + partial class PGAddJobCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelLimit") + .HasColumnType("integer"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ReconnectionInterval") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatSettingsId") + .HasColumnType("bigint"); + + b.Property("DiscordChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("text"); + + b.Property("DMApiMajorVersion") + .HasColumnType("integer"); + + b.Property("DMApiMinorVersion") + .HasColumnType("integer"); + + b.Property("DMApiPatchVersion") + .HasColumnType("integer"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("uuid"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GitHubDeploymentId") + .HasColumnType("integer"); + + b.Property("GitHubRepoId") + .HasColumnType("bigint"); + + b.Property("JobId") + .HasColumnType("bigint"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("integer"); + + b.Property("Output") + .IsRequired() + .HasColumnType("text"); + + b.Property("RepositoryOrigin") + .HasColumnType("text"); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("HealthCheckSeconds") + .HasColumnType("bigint"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("MapThreads") + .HasColumnType("bigint"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("SecurityLevel") + .HasColumnType("integer"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("StartupTimeout") + .HasColumnType("bigint"); + + b.Property("TopicRequestTimeout") + .HasColumnType("bigint"); + + b.Property("Visibility") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiValidationPort") + .HasColumnType("integer"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("integer"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoUpdateInterval") + .HasColumnType("bigint"); + + b.Property("ChatBotLimit") + .HasColumnType("integer"); + + b.Property("ConfigurationType") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Online") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text"); + + b.Property("SwarmIdentifer") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ByondRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ChatBotRights") + .HasColumnType("numeric(20,0)"); + + b.Property("ConfigurationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamDaemonRights") + .HasColumnType("numeric(20,0)"); + + b.Property("DreamMakerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("numeric(20,0)"); + + b.Property("PermissionSetId") + .HasColumnType("bigint"); + + b.Property("RepositoryRights") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelRight") + .HasColumnType("numeric(20,0)"); + + b.Property("CancelRightsType") + .HasColumnType("numeric(20,0)"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CancelledById") + .HasColumnType("bigint"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ErrorCode") + .HasColumnType("bigint"); + + b.Property("ExceptionDetails") + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("JobCode") + .HasColumnType("smallint"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("StartedById") + .HasColumnType("bigint"); + + b.Property("StoppedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdministrationRights") + .HasColumnType("numeric(20,0)"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("InstanceManagerRights") + .HasColumnType("numeric(20,0)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompileJobId") + .HasColumnType("bigint"); + + b.Property("InitialCompileJobId") + .HasColumnType("bigint"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("integer"); + + b.Property("LaunchVisibility") + .HasColumnType("integer"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("ProcessId") + .HasColumnType("integer"); + + b.Property("RebootState") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("UpdateSubmodules") + .IsRequired() + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("RevisionInformationId") + .HasColumnType("bigint"); + + b.Property("TestMergeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("InstanceId") + .HasColumnType("bigint"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("character varying(10000)"); + + b.Property("MergedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MergedById") + .HasColumnType("bigint"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("bigint"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("boolean"); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("LastPasswordUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.cs new file mode 100644 index 0000000000..5bd1b863f6 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.cs @@ -0,0 +1,33 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + public partial class PGAddJobCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.AddColumn( + name: "JobCode", + table: "Jobs", + type: "smallint", + nullable: false, + defaultValue: (byte)0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.DropColumn( + name: "JobCode", + table: "Jobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.Designer.cs new file mode 100644 index 0000000000..718743a339 --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.Designer.cs @@ -0,0 +1,1041 @@ +// +using System; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + [DbContext(typeof(SqliteDatabaseContext))] + [Migration("20231105004820_SLAddJobCodes")] + partial class SLAddJobCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.13"); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ReconnectionInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "Name") + .IsUnique(); + + b.ToTable("ChatBots"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChatSettingsId") + .HasColumnType("INTEGER"); + + b.Property("DiscordChannelId") + .HasColumnType("INTEGER"); + + b.Property("IrcChannel") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsAdminChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsSystemChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsUpdatesChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("IsWatchdogChannel") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Tag") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChatSettingsId", "DiscordChannelId") + .IsUnique(); + + b.HasIndex("ChatSettingsId", "IrcChannel") + .IsUnique(); + + b.ToTable("ChatChannels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DMApiMajorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiMinorVersion") + .HasColumnType("INTEGER"); + + b.Property("DMApiPatchVersion") + .HasColumnType("INTEGER"); + + b.Property("DirectoryName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DmeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GitHubDeploymentId") + .HasColumnType("INTEGER"); + + b.Property("GitHubRepoId") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("MinimumSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("Output") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RepositoryOrigin") + .HasColumnType("TEXT"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DirectoryName"); + + b.HasIndex("JobId") + .IsUnique(); + + b.HasIndex("RevisionInformationId"); + + b.ToTable("CompileJobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalParameters") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("AllowWebClient") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoStart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("DumpOnHealthCheckRestart") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("HealthCheckSeconds") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("LogOutput") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("MapThreads") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Port") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("SecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("StartProfiler") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("StartupTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TopicRequestTimeout") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Visibility") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamDaemonSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationPort") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ApiValidationSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("ProjectName") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("RequireDMApiValidation") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Timeout") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DreamMakerSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdateInterval") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ChatBotLimit") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ConfigurationType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Online") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SwarmIdentifer") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Path", "SwarmIdentifer") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByondRights") + .HasColumnType("INTEGER"); + + b.Property("ChatBotRights") + .HasColumnType("INTEGER"); + + b.Property("ConfigurationRights") + .HasColumnType("INTEGER"); + + b.Property("DreamDaemonRights") + .HasColumnType("INTEGER"); + + b.Property("DreamMakerRights") + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("InstancePermissionSetRights") + .HasColumnType("INTEGER"); + + b.Property("PermissionSetId") + .HasColumnType("INTEGER"); + + b.Property("RepositoryRights") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("PermissionSetId", "InstanceId") + .IsUnique(); + + b.ToTable("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CancelRight") + .HasColumnType("INTEGER"); + + b.Property("CancelRightsType") + .HasColumnType("INTEGER"); + + b.Property("Cancelled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CancelledById") + .HasColumnType("INTEGER"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorCode") + .HasColumnType("INTEGER"); + + b.Property("ExceptionDetails") + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("JobCode") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedById") + .HasColumnType("INTEGER"); + + b.Property("StoppedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CancelledById"); + + b.HasIndex("InstanceId"); + + b.HasIndex("StartedById"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalUserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Provider", "ExternalUserId") + .IsUnique(); + + b.ToTable("OAuthConnections"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdministrationRights") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("InstanceManagerRights") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GroupId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessIdentifier") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompileJobId") + .HasColumnType("INTEGER"); + + b.Property("InitialCompileJobId") + .HasColumnType("INTEGER"); + + b.Property("LaunchSecurityLevel") + .HasColumnType("INTEGER"); + + b.Property("LaunchVisibility") + .HasColumnType("INTEGER"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("ProcessId") + .HasColumnType("INTEGER"); + + b.Property("RebootState") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CompileJobId"); + + b.HasIndex("InitialCompileJobId"); + + b.ToTable("ReattachInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("AccessUser") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("AutoUpdatesKeepTestMerges") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("AutoUpdatesSynchronize") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("CommitterEmail") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("CommitterName") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("CreateGitHubDeployments") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("PostTestMergeComment") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("PushTestMergeCommits") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("ShowTestMergeCommitters") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("UpdateSubmodules") + .IsRequired() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("RepositorySettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RevisionInformationId") + .HasColumnType("INTEGER"); + + b.Property("TestMergeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RevisionInformationId"); + + b.HasIndex("TestMergeId"); + + b.ToTable("RevInfoTestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("InstanceId") + .HasColumnType("INTEGER"); + + b.Property("OriginCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "CommitSha") + .IsUnique(); + + b.ToTable("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BodyAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("MergedAt") + .HasColumnType("TEXT"); + + b.Property("MergedById") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PrimaryRevisionInformationId") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("TargetCommitSha") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("TitleAtMerge") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MergedById"); + + b.HasIndex("PrimaryRevisionInformationId") + .IsUnique(); + + b.ToTable("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedById") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .IsRequired() + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("LastPasswordUpdate") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("SystemIdentifier") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CanonicalName") + .IsUnique(); + + b.HasIndex("CreatedById"); + + b.HasIndex("GroupId"); + + b.HasIndex("SystemIdentifier") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("ChatSettings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatChannel", b => + { + b.HasOne("Tgstation.Server.Host.Models.ChatBot", "ChatSettings") + .WithMany("Channels") + .HasForeignKey("ChatSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatSettings"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.CompileJob", b => + { + b.HasOne("Tgstation.Server.Host.Models.Job", "Job") + .WithOne() + .HasForeignKey("Tgstation.Server.Host.Models.CompileJob", "JobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("CompileJobs") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + + b.Navigation("Job"); + + b.Navigation("RevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamDaemonSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamDaemonSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamDaemonSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.DreamMakerSettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("DreamMakerSettings") + .HasForeignKey("Tgstation.Server.Host.Models.DreamMakerSettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.InstancePermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("InstancePermissionSets") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.PermissionSet", "PermissionSet") + .WithMany("InstancePermissionSets") + .HasForeignKey("PermissionSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + + b.Navigation("PermissionSet"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Job", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CancelledBy") + .WithMany() + .HasForeignKey("CancelledById"); + + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("Jobs") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.User", "StartedBy") + .WithMany() + .HasForeignKey("StartedById") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CancelledBy"); + + b.Navigation("Instance"); + + b.Navigation("StartedBy"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.OAuthConnection", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Tgstation.Server.Host.Models.User", "User") + .WithOne("PermissionSet") + .HasForeignKey("Tgstation.Server.Host.Models.PermissionSet", "UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ReattachInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "CompileJob") + .WithMany() + .HasForeignKey("CompileJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.CompileJob", "InitialCompileJob") + .WithMany() + .HasForeignKey("InitialCompileJobId"); + + b.Navigation("CompileJob"); + + b.Navigation("InitialCompileJob"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RepositorySettings", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithOne("RepositorySettings") + .HasForeignKey("Tgstation.Server.Host.Models.RepositorySettings", "InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevInfoTestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "RevisionInformation") + .WithMany("ActiveTestMerges") + .HasForeignKey("RevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.TestMerge", "TestMerge") + .WithMany("RevisonInformations") + .HasForeignKey("TestMergeId") + .OnDelete(DeleteBehavior.ClientNoAction) + .IsRequired(); + + b.Navigation("RevisionInformation"); + + b.Navigation("TestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.HasOne("Tgstation.Server.Host.Models.Instance", "Instance") + .WithMany("RevisionInformations") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "MergedBy") + .WithMany("TestMerges") + .HasForeignKey("MergedById") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tgstation.Server.Host.Models.RevisionInformation", "PrimaryRevisionInformation") + .WithOne("PrimaryTestMerge") + .HasForeignKey("Tgstation.Server.Host.Models.TestMerge", "PrimaryRevisionInformationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MergedBy"); + + b.Navigation("PrimaryRevisionInformation"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.HasOne("Tgstation.Server.Host.Models.User", "CreatedBy") + .WithMany("CreatedUsers") + .HasForeignKey("CreatedById"); + + b.HasOne("Tgstation.Server.Host.Models.UserGroup", "Group") + .WithMany("Users") + .HasForeignKey("GroupId"); + + b.Navigation("CreatedBy"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.Instance", b => + { + b.Navigation("ChatSettings"); + + b.Navigation("DreamDaemonSettings"); + + b.Navigation("DreamMakerSettings"); + + b.Navigation("InstancePermissionSets"); + + b.Navigation("Jobs"); + + b.Navigation("RepositorySettings"); + + b.Navigation("RevisionInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.PermissionSet", b => + { + b.Navigation("InstancePermissionSets"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.RevisionInformation", b => + { + b.Navigation("ActiveTestMerges"); + + b.Navigation("CompileJobs"); + + b.Navigation("PrimaryTestMerge"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.TestMerge", b => + { + b.Navigation("RevisonInformations"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.User", b => + { + b.Navigation("CreatedUsers"); + + b.Navigation("OAuthConnections"); + + b.Navigation("PermissionSet"); + + b.Navigation("TestMerges"); + }); + + modelBuilder.Entity("Tgstation.Server.Host.Models.UserGroup", b => + { + b.Navigation("PermissionSet") + .IsRequired(); + + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.cs b/src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.cs new file mode 100644 index 0000000000..76a2be02ee --- /dev/null +++ b/src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.cs @@ -0,0 +1,33 @@ +using System; + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tgstation.Server.Host.Database.Migrations +{ + /// + public partial class SLAddJobCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.AddColumn( + name: "JobCode", + table: "Jobs", + type: "INTEGER", + nullable: false, + defaultValue: (byte)0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + ArgumentNullException.ThrowIfNull(migrationBuilder); + migrationBuilder.DropColumn( + name: "JobCode", + table: "Jobs"); + } + } +} diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153302_MSRenameByondColumnsToEngine.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004349_MSRenameByondColumnsToEngine.Designer.cs similarity index 99% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153302_MSRenameByondColumnsToEngine.Designer.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004349_MSRenameByondColumnsToEngine.Designer.cs index 0bbedf9ac1..65b94a6fc7 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20231021153302_MSRenameByondColumnsToEngine.Designer.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20231108004349_MSRenameByondColumnsToEngine.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Tgstation.Server.Host.Database.Migrations { [DbContext(typeof(SqlServerDatabaseContext))] - [Migration("20231021153302_MSRenameByondColumnsToEngine")] + [Migration("20231108004349_MSRenameByondColumnsToEngine")] partial class MSRenameByondColumnsToEngine { /// @@ -331,10 +331,6 @@ namespace Tgstation.Server.Host.Database.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ByondRights") - .HasColumnType("decimal(20,0)") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("decimal(20,0)"); @@ -347,6 +343,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("decimal(20,0)"); + b.Property("EngineRights") + .HasColumnType("decimal(20,0)"); + b.Property("InstanceId") .HasColumnType("bigint"); @@ -403,6 +402,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("bigint"); + b.Property("JobCode") + .HasColumnType("tinyint"); + b.Property("StartedAt") .IsRequired() .HasColumnType("datetimeoffset"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153302_MSRenameByondColumnsToEngine.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004349_MSRenameByondColumnsToEngine.cs similarity index 100% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153302_MSRenameByondColumnsToEngine.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004349_MSRenameByondColumnsToEngine.cs diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153309_MYRenameByondColumnsToEngine.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004356_MYRenameByondColumnsToEngine.Designer.cs similarity index 99% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153309_MYRenameByondColumnsToEngine.Designer.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004356_MYRenameByondColumnsToEngine.Designer.cs index 74af14ef75..ff843c5a04 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20231021153309_MYRenameByondColumnsToEngine.Designer.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20231108004356_MYRenameByondColumnsToEngine.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Tgstation.Server.Host.Database.Migrations { [DbContext(typeof(MySqlDatabaseContext))] - [Migration("20231021153309_MYRenameByondColumnsToEngine")] + [Migration("20231108004356_MYRenameByondColumnsToEngine")] partial class MYRenameByondColumnsToEngine { /// @@ -346,10 +346,6 @@ namespace Tgstation.Server.Host.Database.Migrations .ValueGeneratedOnAdd() .HasColumnType("bigint"); - b.Property("ByondRights") - .HasColumnType("bigint unsigned") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("bigint unsigned"); @@ -362,6 +358,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("bigint unsigned"); + b.Property("EngineRights") + .HasColumnType("bigint unsigned"); + b.Property("InstanceId") .HasColumnType("bigint"); @@ -420,6 +419,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("bigint"); + b.Property("JobCode") + .HasColumnType("tinyint unsigned"); + b.Property("StartedAt") .IsRequired() .HasColumnType("datetime(6)"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153309_MYRenameByondColumnsToEngine.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004356_MYRenameByondColumnsToEngine.cs similarity index 100% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153309_MYRenameByondColumnsToEngine.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004356_MYRenameByondColumnsToEngine.cs diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153316_PGRenameByondColumnsToEngine.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004402_PGRenameByondColumnsToEngine.Designer.cs similarity index 99% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153316_PGRenameByondColumnsToEngine.Designer.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004402_PGRenameByondColumnsToEngine.Designer.cs index c1e33d9358..2689c8ae29 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20231021153316_PGRenameByondColumnsToEngine.Designer.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20231108004402_PGRenameByondColumnsToEngine.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Tgstation.Server.Host.Database.Migrations { [DbContext(typeof(PostgresSqlDatabaseContext))] - [Migration("20231021153316_PGRenameByondColumnsToEngine")] + [Migration("20231108004402_PGRenameByondColumnsToEngine")] partial class PGRenameByondColumnsToEngine { /// @@ -328,10 +328,6 @@ namespace Tgstation.Server.Host.Database.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("ByondRights") - .HasColumnType("numeric(20,0)") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("numeric(20,0)"); @@ -344,6 +340,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("numeric(20,0)"); + b.Property("EngineRights") + .HasColumnType("numeric(20,0)"); + b.Property("InstanceId") .HasColumnType("bigint"); @@ -400,6 +399,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("bigint"); + b.Property("JobCode") + .HasColumnType("smallint"); + b.Property("StartedAt") .IsRequired() .HasColumnType("timestamp with time zone"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153316_PGRenameByondColumnsToEngine.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004402_PGRenameByondColumnsToEngine.cs similarity index 100% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153316_PGRenameByondColumnsToEngine.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004402_PGRenameByondColumnsToEngine.cs diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153323_SLRenameByondColumnsToEngine.Designer.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004409_SLRenameByondColumnsToEngine.Designer.cs similarity index 99% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153323_SLRenameByondColumnsToEngine.Designer.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004409_SLRenameByondColumnsToEngine.Designer.cs index 594d0bbc9d..f34889c73c 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/20231021153323_SLRenameByondColumnsToEngine.Designer.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/20231108004409_SLRenameByondColumnsToEngine.Designer.cs @@ -10,7 +10,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Tgstation.Server.Host.Database.Migrations { [DbContext(typeof(SqliteDatabaseContext))] - [Migration("20231021153323_SLRenameByondColumnsToEngine")] + [Migration("20231108004409_SLRenameByondColumnsToEngine")] partial class SLRenameByondColumnsToEngine { /// @@ -320,10 +320,6 @@ namespace Tgstation.Server.Host.Database.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("ByondRights") - .HasColumnType("INTEGER") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("INTEGER"); @@ -336,6 +332,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("INTEGER"); + b.Property("EngineRights") + .HasColumnType("INTEGER"); + b.Property("InstanceId") .HasColumnType("INTEGER"); @@ -390,6 +389,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("INTEGER"); + b.Property("JobCode") + .HasColumnType("INTEGER"); + b.Property("StartedAt") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/20231021153323_SLRenameByondColumnsToEngine.cs b/src/Tgstation.Server.Host/Database/Migrations/20231108004409_SLRenameByondColumnsToEngine.cs similarity index 100% rename from src/Tgstation.Server.Host/Database/Migrations/20231021153323_SLRenameByondColumnsToEngine.cs rename to src/Tgstation.Server.Host/Database/Migrations/20231108004409_SLRenameByondColumnsToEngine.cs diff --git a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs index 0fe6ad0c6b..713ce53b72 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs @@ -11,7 +11,6 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(MySqlDatabaseContext))] partial class MySqlDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 @@ -344,10 +343,6 @@ namespace Tgstation.Server.Host.Database.Migrations .ValueGeneratedOnAdd() .HasColumnType("bigint"); - b.Property("ByondRights") - .HasColumnType("bigint unsigned") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("bigint unsigned"); @@ -360,6 +355,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("bigint unsigned"); + b.Property("EngineRights") + .HasColumnType("bigint unsigned"); + b.Property("InstanceId") .HasColumnType("bigint"); @@ -418,6 +416,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("bigint"); + b.Property("JobCode") + .HasColumnType("tinyint unsigned"); + b.Property("StartedAt") .IsRequired() .HasColumnType("datetime(6)"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs index bb9d55bc90..5e872a384a 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs @@ -11,7 +11,6 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(PostgresSqlDatabaseContext))] partial class PostgresSqlDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 @@ -326,10 +325,6 @@ namespace Tgstation.Server.Host.Database.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("ByondRights") - .HasColumnType("numeric(20,0)") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("numeric(20,0)"); @@ -342,6 +337,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("numeric(20,0)"); + b.Property("EngineRights") + .HasColumnType("numeric(20,0)"); + b.Property("InstanceId") .HasColumnType("bigint"); @@ -398,6 +396,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("bigint"); + b.Property("JobCode") + .HasColumnType("smallint"); + b.Property("StartedAt") .IsRequired() .HasColumnType("timestamp with time zone"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs index f0ce1f094d..cce9549902 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs @@ -11,7 +11,6 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(SqlServerDatabaseContext))] partial class SqlServerDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 @@ -329,10 +328,6 @@ namespace Tgstation.Server.Host.Database.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ByondRights") - .HasColumnType("decimal(20,0)") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("decimal(20,0)"); @@ -345,6 +340,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("decimal(20,0)"); + b.Property("EngineRights") + .HasColumnType("decimal(20,0)"); + b.Property("InstanceId") .HasColumnType("bigint"); @@ -401,6 +399,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("bigint"); + b.Property("JobCode") + .HasColumnType("tinyint"); + b.Property("StartedAt") .IsRequired() .HasColumnType("datetimeoffset"); diff --git a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs index 9aeb77363b..3c0504bde5 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs @@ -11,7 +11,6 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(SqliteDatabaseContext))] partial class SqliteDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 @@ -318,10 +317,6 @@ namespace Tgstation.Server.Host.Database.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("ByondRights") - .HasColumnType("INTEGER") - .HasColumnName("EngineRights"); - b.Property("ChatBotRights") .HasColumnType("INTEGER"); @@ -334,6 +329,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("DreamMakerRights") .HasColumnType("INTEGER"); + b.Property("EngineRights") + .HasColumnType("INTEGER"); + b.Property("InstanceId") .HasColumnType("INTEGER"); @@ -388,6 +386,9 @@ namespace Tgstation.Server.Host.Database.Migrations b.Property("InstanceId") .HasColumnType("INTEGER"); + b.Property("JobCode") + .HasColumnType("INTEGER"); + b.Property("StartedAt") .IsRequired() .HasColumnType("TEXT"); diff --git a/src/Tgstation.Server.Host/Database/MySqlDatabaseContext.cs b/src/Tgstation.Server.Host/Database/MySqlDatabaseContext.cs index 83d109c425..25f640fc51 100644 --- a/src/Tgstation.Server.Host/Database/MySqlDatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/MySqlDatabaseContext.cs @@ -73,7 +73,7 @@ namespace Tgstation.Server.Host.Database .MapMySqlTextField(x => x.ConnectionString) .MapMySqlTextField(x => x.Tag) .MapMySqlTextField(x => x.IrcChannel) - .MapMySqlTextField(x => x.ByondVersion) + .MapMySqlTextField(x => x.EngineVersion) .MapMySqlTextField(x => x.DmeName) .MapMySqlTextField(x => x.Output) .MapMySqlTextField(x => x.RepositoryOrigin) diff --git a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs index 42bb3ad8fb..55086228dc 100644 --- a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs @@ -8,8 +8,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using Microsoft.Net.Http.Headers; using Serilog.Context; @@ -66,20 +64,6 @@ namespace Tgstation.Server.Host.Extensions }); } - /// - /// Suppress any client side caching of API calls. - /// - /// The to configure. - public static void UseDisabledClientCache(this IApplicationBuilder applicationBuilder) - { - ArgumentNullException.ThrowIfNull(applicationBuilder); - applicationBuilder.Use(async (context, next) => - { - context.Response.Headers.Add(HeaderNames.CacheControl, new StringValues("no-cache")); - await next(); - }); - } - /// /// Suppress warnings when a user aborts a request. /// @@ -135,6 +119,35 @@ namespace Tgstation.Server.Host.Extensions }); } + /// + /// Check that the API version is the current major version if it's present in the headers. + /// + /// The to configure. + public static void UseApiCompatibility(this IApplicationBuilder applicationBuilder) + { + ArgumentNullException.ThrowIfNull(applicationBuilder); + + applicationBuilder.Use(async (context, next) => + { + var apiHeadersProvider = context.RequestServices.GetRequiredService(); + if (apiHeadersProvider.ApiHeaders?.Compatible() == false) + { + await new JsonResult( + new ErrorMessageResponse(ErrorCode.ApiMismatch)) + { + StatusCode = (int)HttpStatusCode.UpgradeRequired, + } + .ExecuteResultAsync(new ActionContext + { + HttpContext = context, + }); + return; + } + + await next(); + }); + } + /// /// Add the X-Powered-By response header. /// diff --git a/src/Tgstation.Server.Host/Extensions/FileTransferStreamHandlerExtensions.cs b/src/Tgstation.Server.Host/Extensions/FileTransferStreamHandlerExtensions.cs index 8028f8ab8d..a7e84abac1 100644 --- a/src/Tgstation.Server.Host/Extensions/FileTransferStreamHandlerExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/FileTransferStreamHandlerExtensions.cs @@ -11,7 +11,7 @@ using Microsoft.Net.Http.Headers; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; -using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Extensions diff --git a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs index c4d7e029b5..c90433f1ff 100644 --- a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Utils; using Tgstation.Server.Host.Utils.GitHub; +using Tgstation.Server.Host.Utils.SignalR; namespace Tgstation.Server.Host.Extensions { @@ -220,6 +221,23 @@ namespace Tgstation.Server.Host.Extensions }); } + /// + /// Attempt to add the given to services. + /// + /// The of the being added. + /// The implementation of the . + /// The to add the to. + public static void AddHub(this IServiceCollection services) + where THub : ConnectionMappingHub + where THubMethods : class + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(typeof(ComprehensiveHubContext<,>)); + services.AddSingleton>(provider => provider.GetRequiredService>()); + services.AddSingleton>(provider => provider.GetRequiredService>()); + } + /// /// Set the modifiable services to their default types. /// diff --git a/src/Tgstation.Server.Host/Jobs/IJobsHubUpdater.cs b/src/Tgstation.Server.Host/Jobs/IJobsHubUpdater.cs new file mode 100644 index 0000000000..ed9080ada7 --- /dev/null +++ b/src/Tgstation.Server.Host/Jobs/IJobsHubUpdater.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Host.Jobs +{ + /// + /// Allows manually triggering jobs hub updates. + /// + interface IJobsHubUpdater + { + /// + /// Queue a message to be sent to all clients with the current state of active jobs. + /// + void QueueActiveJobUpdates(); + } +} diff --git a/src/Tgstation.Server.Host/Jobs/JobService.cs b/src/Tgstation.Server.Host/Jobs/JobService.cs index b5029e2fbe..41a68e56bf 100644 --- a/src/Tgstation.Server.Host/Jobs/JobService.cs +++ b/src/Tgstation.Server.Host/Jobs/JobService.cs @@ -1,12 +1,17 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; + using Serilog.Context; + +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host.Components; @@ -14,12 +19,23 @@ using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Utils; +using Tgstation.Server.Host.Utils.SignalR; namespace Tgstation.Server.Host.Jobs { /// - sealed class JobService : IJobService, IDisposable + sealed class JobService : IJobService, IJobsHubUpdater, IDisposable { + /// + /// The maximum rate at which hub clients can receive updates. + /// + const int MaxHubUpdatesPerSecond = 4; + + /// + /// The for the . + /// + readonly IConnectionMappedHubContext hub; + /// /// The for the . /// @@ -40,6 +56,11 @@ namespace Tgstation.Server.Host.Jobs /// readonly Dictionary jobs; + /// + /// of running s to s that will push immediate updates. + /// + readonly Dictionary hubUpdateActions; + /// /// to delay starting jobs until the server is ready. /// @@ -63,18 +84,23 @@ namespace Tgstation.Server.Host.Jobs /// /// Initializes a new instance of the class. /// + /// The value of . /// The value of . /// The value of . /// The value of . public JobService( + IConnectionMappedHubContext hub, IDatabaseContextFactory databaseContextFactory, ILoggerFactory loggerFactory, ILogger logger) { + this.hub = hub ?? throw new ArgumentNullException(nameof(hub)); this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + jobs = new Dictionary(); + hubUpdateActions = new Dictionary(); activationTcs = new TaskCompletionSource(); synchronizationLock = new object(); addCancelLock = new object(); @@ -100,7 +126,7 @@ namespace Tgstation.Server.Host.Jobs job.Instance = new Models.Instance { - Id = job.Instance.Id, + Id = job.Instance.Id.Value, }; databaseContext.Instances.Attach(job.Instance); @@ -111,7 +137,7 @@ namespace Tgstation.Server.Host.Jobs else job.StartedBy = new User { - Id = job.StartedBy.Id, + Id = job.StartedBy.Id ?? throw new InvalidOperationException("StartedBy User associated with job does not have an Id!"), }; databaseContext.Users.Attach(job.StartedBy); @@ -152,14 +178,14 @@ namespace Tgstation.Server.Host.Jobs .Jobs .AsQueryable() .Where(y => !y.StoppedAt.HasValue) - .Select(y => y.Id) + .Select(y => y.Id.Value) .ToListAsync(cancellationToken); if (badJobIds.Count > 0) { logger.LogTrace("Cleaning {unfinishedJobCount} unfinished jobs...", badJobIds.Count); foreach (var badJobId in badJobIds) { - var job = new Job { Id = badJobId }; + var job = new Job(badJobId); databaseContext.Jobs.Attach(job); job.Cancelled = true; job.StoppedAt = DateTimeOffset.UtcNow; @@ -181,10 +207,7 @@ namespace Tgstation.Server.Host.Jobs { noMoreJobsShouldStart = true; joinTasks = jobs.Select(x => CancelJob( - new Job - { - Id = x.Key, - }, + new Job(x.Key), null, true, cancellationToken)) @@ -214,7 +237,7 @@ namespace Tgstation.Server.Host.Jobs { user ??= await databaseContext.Users.GetTgsUser(cancellationToken); - var updatedJob = new Job { Id = job.Id }; + var updatedJob = new Job(job.Id.Value); databaseContext.Jobs.Attach(updatedJob); var attachedUser = new User { Id = user.Id }; databaseContext.Users.Attach(attachedUser); @@ -269,13 +292,12 @@ namespace Tgstation.Server.Host.Jobs if (noMoreJobsShouldStart && !handler.Started) await Extensions.TaskExtensions.InfiniteTask.WaitAsync(cancellationToken); - ValueTask? cancelTask = null; + var cancelTask = ValueTask.FromResult(null); bool result; using (jobCancellationToken.Register(() => cancelTask = CancelJob(job, canceller, true, cancellationToken))) result = await handler.Wait(cancellationToken); - if (cancelTask.HasValue) - await cancelTask.Value; + await cancelTask; return result; } @@ -289,26 +311,83 @@ namespace Tgstation.Server.Host.Jobs activationTcs.SetResult(instanceCoreProvider); } + /// + public void QueueActiveJobUpdates() + { + lock (hubUpdateActions) + foreach (var action in hubUpdateActions.Values) + action(); + } + /// /// Runner for s. /// - /// The being run. + /// The being run. Must be fully populated. /// The for the . /// The for the operation. /// A representing the running operation. +#pragma warning disable CA1506 // TODO: Decomplexify async Task RunJob(Job job, JobEntrypoint operation, CancellationToken cancellationToken) +#pragma warning restore CA1506 { using (LogContext.PushProperty(SerilogContextHelper.JobIdContextProperty, job.Id)) try { void LogException(Exception ex) => logger.LogDebug(ex, "Job {jobId} exited with error!", job.Id); + var hubUpdatesTask = Task.CompletedTask; var result = false; + + Stopwatch stopwatch = null; + void QueueHubUpdate(JobResponse update, bool final) + { + void NextUpdate(bool bypassRate) + { + var currentUpdatesTask = hubUpdatesTask; + async Task ChainHubUpdate() + { + await currentUpdatesTask; + + // DCT: Cancellation token is for job, operation should always run + await hub + .Clients + .Group(JobsHub.HubGroupName(job)) + .ReceiveJobUpdate(update, CancellationToken.None); + } + + Stopwatch enteredLock = null; + try + { + if (!bypassRate && stopwatch != null) + { + Monitor.Enter(stopwatch); + enteredLock = stopwatch; + if (stopwatch.ElapsedMilliseconds * MaxHubUpdatesPerSecond < 1) + return; // don't spam client + } + + hubUpdatesTask = ChainHubUpdate(); + stopwatch = Stopwatch.StartNew(); + } + finally + { + if (enteredLock != null) + Monitor.Exit(enteredLock); + } + } + + var jobId = update.Id.Value; + lock (hubUpdateActions) + if (final) + hubUpdateActions.Remove(jobId); + else + hubUpdateActions[jobId] = () => NextUpdate(true); + + NextUpdate(false); + } + try { - var oldJob = job; - job = new Job { Id = oldJob.Id }; - void UpdateProgress(string stage, double? progress) { if (progress.HasValue @@ -319,19 +398,26 @@ namespace Tgstation.Server.Host.Jobs return; } + int? newProgress = progress.HasValue ? (int)Math.Floor(progress.Value * 100) : null; lock (synchronizationLock) - if (jobs.TryGetValue(oldJob.Id.Value, out var handler)) + if (jobs.TryGetValue(job.Id.Value, out var handler)) { handler.Stage = stage; - handler.Progress = progress.HasValue ? (int)Math.Floor(progress.Value * 100) : null; + handler.Progress = newProgress; + + var updatedJob = job.ToApi(); + updatedJob.Stage = stage; + updatedJob.Progress = newProgress; + QueueHubUpdate(updatedJob, false); } } var instanceCoreProvider = await activationTcs.Task.WaitAsync(cancellationToken); + QueueHubUpdate(job.ToApi(), false); logger.LogTrace("Starting job..."); await operation( - instanceCoreProvider.GetInstance(oldJob.Instance), + instanceCoreProvider.GetInstance(job.Instance), databaseContextFactory, job, new JobProgressReporter( @@ -360,22 +446,54 @@ namespace Tgstation.Server.Host.Jobs LogException(e); } - await databaseContextFactory.UseContext(async databaseContext => + try { - var attachedJob = new Job + await databaseContextFactory.UseContext(async databaseContext => { - Id = job.Id, - }; + var attachedJob = new Job(job.Id.Value); - databaseContext.Jobs.Attach(attachedJob); - attachedJob.StoppedAt = DateTimeOffset.UtcNow; - attachedJob.ExceptionDetails = job.ExceptionDetails; - attachedJob.ErrorCode = job.ErrorCode; - attachedJob.Cancelled = job.Cancelled; + databaseContext.Jobs.Attach(attachedJob); + attachedJob.StoppedAt = DateTimeOffset.UtcNow; + attachedJob.ExceptionDetails = job.ExceptionDetails; + attachedJob.ErrorCode = job.ErrorCode; + attachedJob.Cancelled = job.Cancelled; - // DCT: Cancellation token is for job, operation should always run - await databaseContext.Save(CancellationToken.None); - }); + // DCT: Cancellation token is for job, operation should always run + await databaseContext.Save(CancellationToken.None); + }); + + // Resetting the context here because I CBA to worry if the cache is being used + await databaseContextFactory.UseContext(async databaseContext => + { + // Cancellation might be set in another async context, forced to reload here for the final hub update + // DCT: Cancellation token is for job, operation should always run + var finalJob = await databaseContext + .Jobs + .AsQueryable() + .Include(x => x.Instance) + .Include(x => x.StartedBy) + .Include(x => x.CancelledBy) + .Where(dbJob => dbJob.Id == job.Id.Value) + .FirstAsync(CancellationToken.None); + QueueHubUpdate(finalJob.ToApi(), true); + }); + } + catch + { + lock (hubUpdateActions) + hubUpdateActions.Remove(job.Id.Value); + + throw; + } + + try + { + await hubUpdatesTask; + } + catch (Exception ex) + { + logger.LogError(ex, "Error in hub updates chain task!"); + } return result; } diff --git a/src/Tgstation.Server.Host/Jobs/JobsHub.cs b/src/Tgstation.Server.Host/Jobs/JobsHub.cs new file mode 100644 index 0000000000..b74e19d22b --- /dev/null +++ b/src/Tgstation.Server.Host/Jobs/JobsHub.cs @@ -0,0 +1,52 @@ +using System; + +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Api.Hubs; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils.SignalR; + +namespace Tgstation.Server.Host.Jobs +{ + /// + /// A SignalR for pushing job updates. + /// + sealed class JobsHub : ConnectionMappingHub + { + /// + /// Get the group name for a given . + /// + /// The . + /// The name of the group for the . + public static string HubGroupName(long instanceId) + => $"instance-{instanceId}"; + + /// + /// Get the group name for a given . + /// + /// The . + /// The name of the group for the . + public static string HubGroupName(Job job) + { + ArgumentNullException.ThrowIfNull(job); + + if (job.Instance == null) + throw new InvalidOperationException("job.Instance was null!"); + + return HubGroupName(job.Instance.Id.Value); + } + + /// + /// Initializes a new instance of the class. + /// + /// The for the . + /// The for the . + public JobsHub( + IHubConnectionMapper connectionMapper, + IAuthenticationContext authenticationContext) + : base(connectionMapper, authenticationContext) + { + } + } +} diff --git a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs new file mode 100644 index 0000000000..719138e6ad --- /dev/null +++ b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Hubs; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils.SignalR; + +namespace Tgstation.Server.Host.Jobs +{ + /// + /// Handles mapping groups for the . + /// + sealed class JobsHubGroupMapper : IPermissionsUpdateNotifyee, IHostedService + { + /// + /// The for the . + /// + readonly IConnectionMappedHubContext hub; + + /// + /// The for the . + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the . + /// + readonly IJobsHubUpdater jobsHubUpdater; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + public JobsHubGroupMapper( + IConnectionMappedHubContext hub, + IDatabaseContextFactory databaseContextFactory, + IJobsHubUpdater jobsHubUpdater, + ILogger logger) + { + this.hub = hub ?? throw new ArgumentNullException(nameof(hub)); + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.jobsHubUpdater = jobsHubUpdater ?? throw new ArgumentNullException(nameof(jobsHubUpdater)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + hub.OnConnectionMapGroups += MapConnectionGroups; + } + + /// + public ValueTask InstancePermissionSetCreated(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(instancePermissionSet); + var permissionSetId = instancePermissionSet.PermissionSet.Id ?? instancePermissionSet.PermissionSetId; + + logger.LogTrace("InstancePermissionSetCreated"); + return RefreshHubGroups( + permissionSetId, + cancellationToken); + } + + /// + public ValueTask UserDisabled(User user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + if (!user.Id.HasValue) + throw new InvalidOperationException("user.Id was null!"); + + logger.LogTrace("UserDisabled"); + hub.AbortUnauthedConnections(user); + return ValueTask.CompletedTask; + } + + /// + public ValueTask InstancePermissionSetDeleted(PermissionSet permissionSet, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(permissionSet); + logger.LogTrace("InstancePermissionSetDeleted"); + return RefreshHubGroups( + permissionSet.Id ?? throw new InvalidOperationException("permissionSet?.Id was null!"), + cancellationToken); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Implementation of . + /// + /// The to map the groups for. + /// The taking the mapped group names as an of resulting in a to be ed. + /// The for the operation. + /// A resulting in an of the group names the user belongs in. + async ValueTask MapConnectionGroups( + IAuthenticationContext authenticationContext, + Func, Task> mappingFunc, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authenticationContext); + + List permedInstanceIds = null; + await databaseContextFactory.UseContext( + async databaseContext => + permedInstanceIds = await databaseContext + .InstancePermissionSets + .AsQueryable() + .Where(ips => ips.PermissionSetId == authenticationContext.PermissionSet.Id.Value) + .Select(ips => ips.Id) + .ToListAsync(cancellationToken)); + + await mappingFunc( + permedInstanceIds.Select( + JobsHub.HubGroupName)); + + jobsHubUpdater.QueueActiveJobUpdates(); + } + + /// + /// Refresh the for clients associated with a given . + /// + /// The of the who's users need updating. + /// The for the operation. + /// A representing the running operation. + ValueTask RefreshHubGroups(long permissionSetId, CancellationToken cancellationToken) + => databaseContextFactory.UseContext( + async databaseContext => + { + logger.LogTrace("RefreshHubGroups"); + var permissionSetUsers = await databaseContext + .Users + .Where(x => x.PermissionSet.Id == permissionSetId) + .ToListAsync(cancellationToken); + var allInstanceIds = await databaseContext + .Instances + .Select( + instance => instance.Id.Value) + .ToListAsync(cancellationToken); + var permissionSetAccessibleInstanceIds = await databaseContext + .InstancePermissionSets + .AsQueryable() + .Where(ips => ips.PermissionSetId == permissionSetId) + .Select(ips => ips.InstanceId) + .ToListAsync(cancellationToken); + + var groupsToRemove = allInstanceIds + .Except(permissionSetAccessibleInstanceIds) + .Select(JobsHub.HubGroupName); + + var groupsToAdd = permissionSetAccessibleInstanceIds + .Select(JobsHub.HubGroupName); + + var connectionIds = permissionSetUsers + .SelectMany(user => hub.UserConnectionIds(user)) + .ToList(); + + logger.LogTrace( + "Updating groups for the {connectionCount} hub connections of permission set {permissionSetId}. They may access {allowed}/{total} instances.", + connectionIds.Count, + permissionSetId, + permissionSetAccessibleInstanceIds.Count, + allInstanceIds.Count); + + var removeTasks = connectionIds + .SelectMany(connectionId => groupsToRemove + .Select(groupName => hub + .Groups + .RemoveFromGroupAsync(connectionId, groupName, cancellationToken))); + + var addTasks = connectionIds + .SelectMany(connectionId => groupsToAdd + .Select(groupName => hub + .Groups + .AddToGroupAsync(connectionId, groupName, cancellationToken))); + + // Checked internally, the default implementations for these tasks complete synchronously + // https://github.com/dotnet/aspnetcore/blob/ce330d9d12f7676ff35c2223bd8a3b1e252a4e86/src/SignalR/server/Core/src/DefaultHubLifetimeManager.cs#L34-L70 + await Task.WhenAll(removeTasks.Concat(addTasks)); + }); + } +} diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 61879a2ef3..372e650484 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -1,6 +1,5 @@ using System; using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; @@ -31,8 +30,7 @@ namespace Tgstation.Server.Host.Models /// The the was made with in string form. /// [Required] - [Column("EngineVersion")] - public string ByondVersion { get; set; } + public string EngineVersion { get; set; } /// /// Backing field for of . @@ -92,12 +90,9 @@ namespace Tgstation.Server.Host.Models Job = Job.ToApi(), Output = Output, RevisionInformation = RevisionInformation.ToApi(), -#pragma warning disable CS0618 // Type or member is obsolete - ByondVersion = ByondVersion, -#pragma warning restore CS0618 // Type or member is obsolete - EngineVersion = Api.Models.Internal.EngineVersion.TryParse(ByondVersion, out var version) + EngineVersion = Api.Models.Internal.EngineVersion.TryParse(EngineVersion, out var version) ? version - : throw new InvalidOperationException($"Failed to parse BYOND version: {ByondVersion}"), + : throw new InvalidOperationException($"Failed to parse engine version: {EngineVersion}"), MinimumSecurityLevel = MinimumSecurityLevel, DMApiVersion = DMApiVersion, RepositoryOrigin = RepositoryOrigin != null ? new Uri(RepositoryOrigin) : null, diff --git a/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs index 56604ecaee..623e920734 100644 --- a/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs +++ b/src/Tgstation.Server.Host/Models/InstancePermissionSet.cs @@ -32,9 +32,6 @@ namespace Tgstation.Server.Host.Models /// public InstancePermissionSetResponse ToApi() => new InstancePermissionSetResponse { -#pragma warning disable CS0618 // Type or member is obsolete - ByondRights = EngineRights, -#pragma warning restore CS0618 // Type or member is obsolete EngineRights = EngineRights, ChatBotRights = ChatBotRights, ConfigurationRights = ConfigurationRights, diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index 1a8fca57c0..8193402a6a 100644 --- a/src/Tgstation.Server.Host/Models/Job.cs +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -1,6 +1,11 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Host.Models { @@ -26,10 +31,89 @@ namespace Tgstation.Server.Host.Models [Required] public Instance Instance { get; set; } + /// + /// Creates a new job for registering in the . + /// + /// The of . + /// The value of . will be derived from this. + /// The value of . If , the user will be used. + /// The used to generate the value of . + /// The value of . will be derived from this. + /// A new ready to be registered with the . + public static Job Create(JobCode code, User startedBy, Api.Models.Instance instance, TRight cancelRight) + where TRight : Enum + => new ( + code, + startedBy, + instance, + RightsHelper.TypeToRight(), + (ulong)(object)cancelRight); + + /// + /// Creates a new job for registering in the . + /// + /// The value of . will be derived from this. + /// The value of . If , the user will be used. + /// The used to generate the value of . + /// A new ready to be registered with the . + public static Job Create(JobCode code, User startedBy, Api.Models.Instance instance) + => new ( + code, + startedBy, + instance, + null, + null); + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("For use by EFCore only", true)] + public Job() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public Job(long id) + { + Id = id; + } + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + /// The value of . + /// The value of . + Job(JobCode code, User startedBy, Api.Models.Instance instance, RightsType? cancelRightsType, ulong? cancelRight) + { + StartedBy = startedBy; + ArgumentNullException.ThrowIfNull(instance); + Instance = new Instance + { + Id = instance.Id ?? throw new InvalidOperationException("Instance associated with job does not have an Id!"), + }; + Description = typeof(JobCode) + .GetField(code.ToString()) + .GetCustomAttributes(false) + .OfType() + .First() + .Description; + JobCode = code; + CancelRight = cancelRight; + CancelRightsType = cancelRightsType; + } + /// public JobResponse ToApi() => new () { Id = Id, + JobCode = JobCode.Value, + InstanceId = Instance.Id.Value, StartedAt = StartedAt, StoppedAt = StoppedAt, Cancelled = Cancelled, diff --git a/src/Tgstation.Server.Host/Models/UserGroup.cs b/src/Tgstation.Server.Host/Models/UserGroup.cs index d914fe872b..11329e9397 100644 --- a/src/Tgstation.Server.Host/Models/UserGroup.cs +++ b/src/Tgstation.Server.Host/Models/UserGroup.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Models /// /// If should be populated. /// A new . - public UserGroupResponse ToApi(bool showUsers) => new UserGroupResponse + public UserGroupResponse ToApi(bool showUsers) => new () { Id = Id, Name = Name, diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 1b19fc46d3..5ed4dafe3f 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -7,19 +7,22 @@ using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { /// - sealed class AuthenticationContext : IAuthenticationContext + sealed class AuthenticationContext : IAuthenticationContext, IDisposable { /// - public User User { get; } + public bool Valid { get; private set; } /// - public PermissionSet PermissionSet { get; } + public User User { get; private set; } /// - public InstancePermissionSet InstancePermissionSet { get; } + public PermissionSet PermissionSet { get; private set; } /// - public ISystemIdentity SystemIdentity { get; } + public InstancePermissionSet InstancePermissionSet { get; private set; } + + /// + public ISystemIdentity SystemIdentity { get; private set; } /// /// Initializes a new instance of the class. @@ -28,13 +31,16 @@ namespace Tgstation.Server.Host.Security { } + /// + public void Dispose() => SystemIdentity?.Dispose(); + /// - /// Initializes a new instance of the class. + /// Initializes the . /// /// The value of . /// The value of . /// The value of . - public AuthenticationContext(ISystemIdentity systemIdentity, User user, InstancePermissionSet instanceUser) + public void Initialize(ISystemIdentity systemIdentity, User user, InstancePermissionSet instanceUser) { User = user ?? throw new ArgumentNullException(nameof(user)); if (systemIdentity == null && User.SystemIdentifier != null) @@ -44,10 +50,9 @@ namespace Tgstation.Server.Host.Security ?? throw new ArgumentException("No PermissionSet provider", nameof(user)); InstancePermissionSet = instanceUser; SystemIdentity = systemIdentity; - } - /// - public void Dispose() => SystemIdentity?.Dispose(); + Valid = true; + } /// public ulong GetRight(RightsType rightsType) @@ -69,7 +74,11 @@ namespace Tgstation.Server.Host.Security var prop = typeToCheck.GetProperties().Where(x => x.PropertyType == nullableRightsType).First(); - var right = prop.GetMethod.Invoke(isInstance ? InstancePermissionSet : PermissionSet, Array.Empty()); + var right = prop.GetMethod.Invoke( + isInstance + ? InstancePermissionSet + : PermissionSet, + Array.Empty()); if (right == null) throw new InvalidOperationException("A user right was null!"); diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs new file mode 100644 index 0000000000..562f49f443 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs @@ -0,0 +1,53 @@ +using System; +using System.Security.Claims; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace Tgstation.Server.Host.Security +{ + /// + /// An that maps s using an . + /// + sealed class AuthenticationContextAuthorizationFilter : IAuthorizationFilter + { + /// + /// The for the . + /// + readonly IAuthenticationContext authenticationContext; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthenticationContextAuthorizationFilter(IAuthenticationContext authenticationContext, ILogger logger) + { + this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public void OnAuthorization(AuthorizationFilterContext context) + { + if (!authenticationContext.Valid) + { + logger.LogTrace("authenticationContext is invalid!"); + context.Result = new UnauthorizedResult(); + return; + } + + if (authenticationContext.User.Enabled.Value) + return; + + logger.LogTrace("authenticationContext is for a disabled user!"); + context.Result = new ForbidResult(); + } + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs new file mode 100644 index 0000000000..4f02bb0b0d --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.IdentityModel.Tokens; + +using Tgstation.Server.Api; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Host.Security +{ + /// + /// A that maps s using an . + /// + sealed class AuthenticationContextClaimsTransformation : IClaimsTransformation + { + /// + /// The for the . + /// + readonly IAuthenticationContextFactory authenticationContextFactory; + + /// + /// The for the . + /// + readonly ApiHeaders apiHeaders; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The containing the value of . + public AuthenticationContextClaimsTransformation(IAuthenticationContextFactory authenticationContextFactory, IApiHeadersProvider apiHeadersProvider) + { + this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); + ArgumentNullException.ThrowIfNull(apiHeadersProvider); + apiHeaders = apiHeadersProvider.ApiHeaders; + } + + /// + public async Task TransformAsync(ClaimsPrincipal principal) + { + ArgumentNullException.ThrowIfNull(principal); + + var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); + if (userIdClaim == default) + throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!"); + + long userId; + try + { + userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to parse user ID!", e); + } + + var nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf); + if (nbfClaim == default) + throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!"); + + DateTimeOffset nbf; + try + { + nbf = new DateTimeOffset( + EpochTime.DateTime( + Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture))); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to parse nbf!", ex); + } + + var authenticationContext = await authenticationContextFactory.CreateAuthenticationContext( + userId, + apiHeaders?.InstanceId, + nbf, + CancellationToken.None); // DCT: None available + + if (authenticationContext.Valid) + { + var enumerator = Enum.GetValues(typeof(RightsType)); + var claims = new List(); + foreach (RightsType rightType in enumerator) + { + // if there's no instance user, do a weird thing and add all the instance roles + // we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid + // if user is null that means they got the token with an expired password + var rightAsULong = authenticationContext.User == null + || (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) + ? ~0UL + : authenticationContext.GetRight(rightType); + var rightEnum = RightsHelper.RightToType(rightType); + var right = (Enum)Enum.ToObject(rightEnum, rightAsULong); + foreach (Enum enumeratedRight in Enum.GetValues(rightEnum)) + if (right.HasFlag(enumeratedRight)) + claims.Add( + new Claim( + ClaimTypes.Role, + RightsHelper.RoleName(rightType, enumeratedRight))); + } + + principal.AddIdentity(new ClaimsIdentity(claims)); + } + + return principal; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 7288dc00cf..f39daaa989 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -13,11 +13,13 @@ using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security { - /// + /// sealed class AuthenticationContextFactory : IAuthenticationContextFactory, IDisposable { - /// - public IAuthenticationContext CurrentAuthenticationContext { get; private set; } + /// + /// The the created. + /// + public IAuthenticationContext CurrentAuthenticationContext => currentAuthenticationContext; /// /// The for the . @@ -39,6 +41,16 @@ namespace Tgstation.Server.Host.Security /// readonly SwarmConfiguration swarmConfiguration; + /// + /// Backing field for . + /// + readonly AuthenticationContext currentAuthenticationContext; + + /// + /// 1 if was initialized, 0 otherwise. + /// + int initialized; + /// /// Initializes a new instance of the class. /// @@ -56,15 +68,17 @@ namespace Tgstation.Server.Host.Security this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache)); swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + currentAuthenticationContext = new AuthenticationContext(); } /// - public void Dispose() => CurrentAuthenticationContext?.Dispose(); + public void Dispose() => currentAuthenticationContext.Dispose(); /// - public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset validAfter, CancellationToken cancellationToken) + public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken) { - if (CurrentAuthenticationContext != null) + if (Interlocked.Exchange(ref initialized, 1) != 0) throw new InvalidOperationException("Authentication context has already been loaded"); var user = await databaseContext @@ -79,9 +93,8 @@ namespace Tgstation.Server.Host.Security .FirstOrDefaultAsync(cancellationToken); if (user == default) { - logger.LogWarning("Unable to find user with ID {0}!", userId); - CurrentAuthenticationContext = new AuthenticationContext(); - return; + logger.LogWarning("Unable to find user with ID {userId}!", userId); + return currentAuthenticationContext; } ISystemIdentity systemIdentity; @@ -89,11 +102,10 @@ namespace Tgstation.Server.Host.Security systemIdentity = identityCache.LoadCachedIdentity(user); else { - if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter) + if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > notBefore) { - logger.LogDebug("Rejecting token for user {0} created before last password update: {1}", userId, user.LastPasswordUpdate.Value); - CurrentAuthenticationContext = new AuthenticationContext(); - return; + logger.LogDebug("Rejecting token for user {userId} created before last password update: {lastPasswordUpdate}", userId, user.LastPasswordUpdate.Value); + return currentAuthenticationContext; } systemIdentity = null; @@ -112,13 +124,14 @@ namespace Tgstation.Server.Host.Security .FirstOrDefaultAsync(cancellationToken); if (instancePermissionSet == null) - logger.LogDebug("User {0} does not have permissions on instance {1}!", userId, instanceId.Value); + logger.LogDebug("User {userId} does not have permissions on instance {instanceId}!", userId, instanceId.Value); } - CurrentAuthenticationContext = new AuthenticationContext( + currentAuthenticationContext.Initialize( systemIdentity, user, instancePermissionSet); + return currentAuthenticationContext; } catch { diff --git a/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs b/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs new file mode 100644 index 0000000000..38360cab9b --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace Tgstation.Server.Host.Security +{ + /// + /// An that denies method calls and connections if the is not valid for an authorized user. + /// + sealed class AuthorizationContextHubFilter : IHubFilter + { + /// + /// The for the . + /// + readonly IAuthenticationContext authenticationContext; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public AuthorizationContextHubFilter( + IAuthenticationContext authenticationContext, + ILogger logger) + { + this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task OnConnectedAsync(HubLifetimeContext context, Func next) + { + ArgumentNullException.ThrowIfNull(context); + if (ValidateAuthenticationContext(context.Hub)) + await next(context); + } + + /// + public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) + { + ArgumentNullException.ThrowIfNull(invocationContext); + if (ValidateAuthenticationContext(invocationContext.Hub)) + return await next(invocationContext); + + return null; + } + + /// + /// Validates the for the hub event. + /// + /// The current . + /// if the hub call should continue, if it shouldn't and has been aborted. + bool ValidateAuthenticationContext(Hub hub) + { + if (!authenticationContext.Valid) + logger.LogTrace("The token for connection {connectionId} is no longer authenticated! Aborting...", hub.Context.ConnectionId); + else if (!authenticationContext.User.Enabled.Value) + logger.LogTrace("The token for connection {connectionId} is no longer authorized! Aborting...", hub.Context.ConnectionId); + else + return true; + + var hubType = hub.GetType(); + var allHubProperties = hubType.GetProperties(); + var typedClientsProperty = allHubProperties.Single( + prop => prop.PropertyType.IsConstructedGenericType + && prop.Name == nameof(hub.Clients)); + var clients = typedClientsProperty.GetValue(hub); + var callerProperty = clients.GetType().GetProperty(nameof(hub.Clients.Caller)); + var caller = callerProperty.GetValue(clients); + + hub.Context.Abort(); + return false; + } + } +} diff --git a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs b/src/Tgstation.Server.Host/Security/ClaimsInjector.cs deleted file mode 100644 index a441af0f16..0000000000 --- a/src/Tgstation.Server.Host/Security/ClaimsInjector.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Http; - -using Tgstation.Server.Api; -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Host.Security -{ - /// - sealed class ClaimsInjector : IClaimsInjector - { - /// - /// The for the . - /// - readonly IAuthenticationContextFactory authenticationContextFactory; - - /// - /// Initializes a new instance of the class. - /// - /// The value of . - public ClaimsInjector(IAuthenticationContextFactory authenticationContextFactory) - { - this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory)); - } - - /// - public async Task InjectClaimsIntoContext(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(tokenValidatedContext); - - // Find the user id in the token - var userIdClaim = tokenValidatedContext.Principal.FindFirst(JwtRegisteredClaimNames.Sub); - if (userIdClaim == default) - 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(tokenValidatedContext.HttpContext.Request.GetTypedHeaders()); - } - catch (HeadersException) - { - // we are not responsible for handling header validation issues - return; - } - - // This populates the CurrentAuthenticationContext field for use by us and subsequent controllers - await authenticationContextFactory.CreateAuthenticationContext( - userId, - apiHeaders.InstanceId, - tokenValidatedContext.SecurityToken.ValidFrom, - cancellationToken); - - var authenticationContext = authenticationContextFactory.CurrentAuthenticationContext; - - var enumerator = Enum.GetValues(typeof(RightsType)); - var claims = new List(); - foreach (RightsType rightType in enumerator) - { - // if there's no instance user, do a weird thing and add all the instance roles - // we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid - // if user is null that means they got the token with an expired password - var rightAsULong = authenticationContext.User == null - || (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null) - ? ~0UL - : authenticationContext.GetRight(rightType); - var rightEnum = RightsHelper.RightToType(rightType); - var right = (Enum)Enum.ToObject(rightEnum, rightAsULong); - foreach (Enum enumeratedRight in Enum.GetValues(rightEnum)) - if (right.HasFlag(enumeratedRight)) - claims.Add(new Claim(ClaimTypes.Role, RightsHelper.RoleName(rightType, enumeratedRight))); - } - - tokenValidatedContext.Principal.AddIdentity(new ClaimsIdentity(claims)); - } - } -} diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 55e68a4cc1..36abee2b2c 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -1,6 +1,4 @@ -using System; - -using Tgstation.Server.Api.Rights; +using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; namespace Tgstation.Server.Host.Security @@ -8,8 +6,13 @@ namespace Tgstation.Server.Host.Security /// /// Represents the currently authenticated . /// - public interface IAuthenticationContext : IDisposable + public interface IAuthenticationContext { + /// + /// If the is for a valid login. + /// + public bool Valid { get; } + /// /// The authenticated user. /// diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs index 9017765615..cb70a50b18 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs @@ -10,18 +10,13 @@ namespace Tgstation.Server.Host.Security public interface IAuthenticationContextFactory { /// - /// The the created. + /// Create an in the request pipeline for a given and . /// - IAuthenticationContext CurrentAuthenticationContext { get; } - - /// - /// Create an to populate . - /// - /// The of the . - /// The of the operation. - /// The the resulting 's password must be valid after. + /// The of the . + /// The of the for the operation. + /// The the login must not be from before. /// The for the operation. - /// A representing the running operation. - ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset validAfter, CancellationToken cancellationToken); + /// A resulting in the created . + ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Security/IClaimsInjector.cs b/src/Tgstation.Server.Host/Security/IClaimsInjector.cs deleted file mode 100644 index 52dbebe6af..0000000000 --- a/src/Tgstation.Server.Host/Security/IClaimsInjector.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.AspNetCore.Authentication.JwtBearer; - -namespace Tgstation.Server.Host.Security -{ - /// - /// For injecting s that can look for. - /// - interface IClaimsInjector - { - /// - /// Setup the s for a given . - /// - /// The containing the and of the request and the to add s to. - /// The for the operation. - /// A representing the running operation. - Task InjectClaimsIntoContext(TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Host/Security/IPermissionsUpdateNotifyee.cs b/src/Tgstation.Server.Host/Security/IPermissionsUpdateNotifyee.cs new file mode 100644 index 0000000000..7a95f7f076 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/IPermissionsUpdateNotifyee.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; + +using Tgstation.Server.Host.Models; + +namespace Tgstation.Server.Host.Security +{ + /// + /// Receives notifications about permissions updates. + /// + public interface IPermissionsUpdateNotifyee + { + /// + /// Called when a given is successfully created. + /// + /// The . must be populated. + /// The for the operation. + /// A representing the running operation. + ValueTask InstancePermissionSetCreated(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken); + + /// + /// Called when an is successfully deleted. + /// + /// The of the deleted . + /// The for the operation. + /// A representing the running operation. + ValueTask InstancePermissionSetDeleted(PermissionSet permissionSet, CancellationToken cancellationToken); + + /// + /// Called when a given is successfully disabled. + /// + /// The that was disabled. + /// The for the operation. + /// A representing the running operation. + ValueTask UserDisabled(User user, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index 6df27f813f..bf9a70fab1 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -1,7 +1,4 @@ -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Tokens; using Tgstation.Server.Api.Models.Response; @@ -22,8 +19,7 @@ namespace Tgstation.Server.Host.Security /// /// The to create the token for. Must have the field available. /// Whether or not this is an OAuth login. - /// The for the operation. - /// A resulting in a new . - ValueTask CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken); + /// A new . + TokenResponse CreateToken(Models.User user, bool oAuth); } } diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index 80f5afaccf..404b27e162 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -1,12 +1,79 @@ # Security Classes - [IAuthenticationContext](./IAuthenticationContext.cs) and [implementation](./AuthenticationContext.cs) is what contains information about an authenticated user for a request. Includes things like the relevant `InstanceUser` and any associated rights. -- [IAuthenticationContextFactory](./IAuthenticationContextFactory.cs) and [implementation](AuthenticationContextFactory.cs) is a factory for `IAuthenticationContext`s. It handles things related to the database for a user's authentication. This includes loading their rights/associated instance user. It will also stop the request if the users token was issued before the last time their password or enabled status was updated. -- [IClaimsInjector](./IClaimsInjector.cs) and [implementation](./ClaimsInjector.cs) is used to associate rights with a request context so that it may properly pass appropriate `TgsAuthorizeAttribute`s. +- [IAuthenticationContextFactory](./IAuthenticationContextFactory.cs) and [implementation](AuthenticationContextFactory.cs) is a factory for `IAuthenticationContext`s. It handles things related to the database for a user's authentication. This includes loading their rights/associated instance user. It will also stop the request if the users token was issued before the last time their password or `Enabled` status was updated. +- [AuthenticationContextClaimsTransformation](./AuthenticationContextClaimsTransformation.cs) is used to associate rights with a request context so that it may properly pass appropriate `TgsAuthorizeAttribute`s. - [ICrytopgraphySuite](./ICrytopgraphySuite.cs) and [implementation](./CrytopgraphySuite.cs) is used to generate secure strings and byte arrays. It also contains the password hashing and validation logic. - [IIdentityCache](./IIdentityCache.cs) and [implementation](./IdentityCache.cs) is used to store `ISystemIdentity`s for the duration of their associated tokens as [IdentityCacheObject](./IdentityCacheObject.cs)s. - [ITokenFactory](./ITokenFactory.cs) and [implementation](./TokenFactory.cs) is used to generate the Json Web Token for a session after a user successfully authenticates. - [ISystemIdentity](./ISystemIdentity.cs)s represent a logon session with the operating system for a given user. It contains a method to run code under the security context of said user. - [ISystemIdentityFactory](./ISystemIdentityFactory.cs) is used to create `ISystemIdentity`s by attempting to log the user in with the OS with a given username and password. +- [TgsAuthorizeAttribute](./TgsAuthorizeAttribute.cs) is a special attribute applied to controller methods to define which rights are required to run a verb. - [OAuth](./OAuth) contains classes related to OAuth 2.0 authentication + +# A Basic Rundown of the Authenticaton Pipeline + +## For the login request (`POST /`) + +1. An attempt to parse the `ApiHeaders` is made. If they were valid, the API version check is performed. If it fails, HTTP 426 with an `ErrorMessageResponse` will be returned. +1. If, for some reason, the user attempts to use a JWT to authenticate this request, steps 2-4 of the non-login pipeline list below are performed. +1. The `ApiController` base class inspects the request. + - At this point, if the `ApiHeaders` (MINUS the `Authorization` header) cannot be properly parsed, HTTP 400 with an `ErrorMessageResponse` is returned. +1. The `HomeController` inspects the request. + 1. If the `ApiHeaders` could not be properly parsed, HTTP 400 (or 406 if the `Accept` header was bad) with an `ErrorMessageResponse` is returned. + - The `WWW-Authenticate` header will be set in this response. + 1. If authentication succeeded using a JWT `Bearer` token, HTTP 400 with an `ErrorMessageResponse` is returned. Refreshing a login using a token is not permitted. + 1. At this point, the path diverges based on the credential type. + - If the user is using a username/password combo: + 1. The username and password combination is tried against the OS authentication system (currently a no-op on Linux). + - If it succeeds, the session is held on to for future reference and the database is queried for a user matching the SID/UID of the login session. + - Otherwise, the database is queried for a user matching the canonicalized username. + - If the user is using an OAuth code: + 1. If the OAuth provider is disabled in the configuration, HTTP 400 with an `ErrorMessageResponse` is returned. + 1. The code is sent to the external provider for validation + - If the provider is GitHub, there's a chance that this could fail due to rate limiting. In this case, HTTP 429 is returned. + - If the provider rejects the OAuth code, HTTP 401 is returned. + 1. The database is queried for a user matching the OAuth provider and external user identifier sent with the OAuth provider's response. + 1. If the query selected above produces no results, HTTP 401 is returned. + 1. For non-OAuth logins, maintenance is performed on the user's DB entry at this point + - For non-OS logins: + 1. The provided password is hashed and checked against the database entry. If it does not match, HTTP 401 will be returned. + - This can potentially cause a change to the DB's stored `PasswordHash` if TGS has updated its dependencies and Microsoft has decided to deprecate the previous hashing method since the user last logged in. + - If this occurs, it invalidates all previous logins for the user. + - For OS logins: + 1. If the `PasswordHash` in the DB isn't null, it is set as such. This invalidates all previous logins for the user. + 1. If the `Name` in the DB does not match the user's OS login, it is updated. + 1. If the user's database entry says they are not enabled, HTTP 403 is returned. + 1. A token is generated from the [ITokenFactory](./ITokenFactory.cs). + 1. For OS logins, the user's login session is cached for the duration of the token's validity plus 15 seconds. + 1. The token is returned as a `TokenResponse` with an HTTP 200 status code. + +## For all other authenticated requests + +1. An attempt to parse the `ApiHeaders` is made. If they were valid. The API version check is performed. If it fails, HTTP 426 with an `ErrorMessageResponse` will be returned. +1. The JWT, if present, is validated. If it is, the scope's [AuthenticationContextFactory](./AuthenticationContextFactory.cs) has `SetTokenNbf` called. If not, HTTP 401 will be returned. + - Inside ASP.NET Core, this initializes the calling user's identity principal and sets the "sub" claim to the TGS user ID parsed out of the JWT. + - We know it's the user ID because we set it up like that in the [TokenFactory](./TokenFactory.cs) +1. The [AuthenticationContextClaimsTransformation](./AuthenticationContextClaimsTransformation.cs) is run (this does not short circuit to responses). + 1. This invokes `IAuthenticationContextFactory.CreateAuthenticationContext` using the "sub" claim from the user's identity and the "nbf" timestamp set earlier (We don't get this from the scope's [IApiHeadersProvider](./IApiHeadersProvider.cs) because there may be other errors preventing the `ApiHeaders` from being parsed). + - At this point, the database lookup using the user ID occurs. This hyrates the scope's [AuthenticationContext](./AuthenticationContext.cs) (which is available at the start of the request, but uninitialized). If the user is a system user, their login session is pulled from the cache. This is also where the instance data for a request is loaded if the user has a valid `InstancePermissionSet` for that instance. The user needs to have a few prerequisites for a valid [IAuthenticationContext](./IAuthenticationContext.cs) to be generated: + - The user with the matching ID must exist in the database. + - The last time the user's password or `Enabled` status changed must be before the "nbf" of their token" + - If the user logged in with an OS login, the session is retrieved from the cache here and added to the scope's [AuthenticationContext](./AuthenticationContext.cs). + 1. If a valid authentication context is returned from the [IAuthenticationContextFactory](./IAuthenticationContextFactory.cs), the [AuthenticationContextClaimsTransformation](./AuthenticationContextClaimsTransformation.cs) uses the context to add claims for each permission bit to the user's identity principal. + - Internally, ASP.NET Core uses this to determine whether or not a request to an endpoint will 403 or not based on the parameters of its [TgsAuthorizeAttribute](./TgsAuthorizeAttribute.cs). +1. The authorization filter is invoked + - For non-SignalR hub requests, this is the [AuthenticationContextAuthorizationFilter](./AuthenticationContextAuthorizationFilter.cs). It does two simple things: + 1. It checks the validity of the scope's [IAuthenticationContext](./IAuthenticationContext.cs). If it is invalid (indicating the user is not authorized either due to not existing (Only possible with a forged and signed JWT) or if their token was outdated compared to the last time their password or `Enabled` status was updated), HTTP 401 will be returned. + 1. It checks the user's `Enabled` status. If the user is disabled, HTTP 403 will be returned. + - For SignalR hub requests, this is the [AuthorizationContextHubFilter](./AuthorizationContextHubFilter.cs). + - If either [IAuthenticationContext](./IAuthenticationContext.cs) is either invalid OR unauthorized, it unceremoniously aborts the connection. +1. The `ApiController` base class inspects the request. + 1. If the `ApiHeaders` could not be properly parsed, HTTP 400 (or 406 if the `Accept` header was bad) with an `ErrorMessageResponse` is returned. + 1. If the request is to an Instance component path: + 1. If there is no valid `Instance` header, HTTP 400 with an `ErrorMessageResponse` is returned. + 1. If the active [IAuthenticationContext](./IAuthenticationContext.cs) has no instance data loaded (indicating the user is not authorized to access said instance), HTTP 403 is returned. + 1. If the instance is offline, HTTP 409 with an `ErrorMessageResponse` is returned. + 1. If the request takes an API model as a parameters and the model included in the request body encountered validation errors, HTTP 400 with an `ErrorMessageResponse` is returned. +1. The request at this point, is considered authorized. Remaining behaviour is left up to each individual route to implement. diff --git a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs similarity index 98% rename from src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs rename to src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs index f9059358e4..0fc4c11c1a 100644 --- a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Security/TgsAuthorizeAttribute.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Tgstation.Server.Api.Rights; -namespace Tgstation.Server.Host.Controllers +namespace Tgstation.Server.Host.Security { /// /// Helper for using the with the system. diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index 81a96876f6..c45a282e2a 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -1,9 +1,9 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Security.Claims; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -11,7 +11,6 @@ using Microsoft.IdentityModel.Tokens; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.System; -using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Security { @@ -26,16 +25,6 @@ namespace Tgstation.Server.Host.Security /// readonly SecurityConfiguration securityConfiguration; - /// - /// The claim. - /// - readonly Claim issuerClaim; - - /// - /// The claim. - /// - readonly Claim audienceClaim; - /// /// The for generating tokens. /// @@ -46,26 +35,17 @@ namespace Tgstation.Server.Host.Security /// readonly JwtSecurityTokenHandler tokenHandler; - /// - /// The for the . - /// - readonly IAsyncDelayer asyncDelayer; - /// /// Initializes a new instance of the class. /// - /// The value of . /// The used for generating the . /// The used to generate the issuer name. /// The containing the value of . public TokenFactory( - IAsyncDelayer asyncDelayer, ICryptographySuite cryptographySuite, IAssemblyInformationProvider assemblyInformationProvider, IOptions securityConfigurationOptions) { - this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer)); - ArgumentNullException.ThrowIfNull(cryptographySuite); ArgumentNullException.ThrowIfNull(assemblyInformationProvider); @@ -94,8 +74,6 @@ namespace Tgstation.Server.Host.Security RequireExpirationTime = true, }; - issuerClaim = new Claim(JwtRegisteredClaimNames.Iss, ValidationParameters.ValidIssuer); - audienceClaim = new Claim(JwtRegisteredClaimNames.Aud, ValidationParameters.ValidAudience); tokenHeader = new JwtHeader( new SigningCredentials( ValidationParameters.IssuerSigningKey, @@ -104,7 +82,7 @@ namespace Tgstation.Server.Host.Security } /// - public async ValueTask CreateToken(Models.User user, bool oAuth, CancellationToken cancellationToken) + public TokenResponse CreateToken(Models.User user, bool oAuth) { ArgumentNullException.ThrowIfNull(user); @@ -112,35 +90,42 @@ namespace Tgstation.Server.Host.Security var nowUnix = now.ToUnixTimeSeconds(); // this prevents validation conflicts down the line - // tldr we can (theoretically) send a token the same second we receive it + // tldr we can (theoretically) receive a token the same second after we generate it // since unix time rounds down, it looks like it came from before the user changed their password // this happens occasionally in unit tests // just delay a second so we can force a round up var userLastPassworUpdateUnix = user.LastPasswordUpdate?.ToUnixTimeSeconds(); + DateTimeOffset notBefore; if (nowUnix == userLastPassworUpdateUnix) - await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken); + notBefore = now.AddSeconds(1); + else + notBefore = now; var expiry = now.AddMinutes(oAuth ? securityConfiguration.OAuthTokenExpiryMinutes : securityConfiguration.TokenExpiryMinutes); - var claims = new Claim[] - { - new Claim(JwtRegisteredClaimNames.Sub, user.Id.Value.ToString(CultureInfo.InvariantCulture)), - new Claim(JwtRegisteredClaimNames.Exp, expiry.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)), - new Claim(JwtRegisteredClaimNames.Nbf, nowUnix.ToString(CultureInfo.InvariantCulture)), - issuerClaim, - audienceClaim, - }; var securityToken = new JwtSecurityToken( tokenHeader, - new JwtPayload(claims)); + new JwtPayload( + ValidationParameters.ValidIssuer, + ValidationParameters.ValidAudience, + Enumerable.Empty(), + new Dictionary + { + { JwtRegisteredClaimNames.Sub, user.Id.Value.ToString(CultureInfo.InvariantCulture) }, + }, + notBefore.UtcDateTime, + expiry.UtcDateTime, + now.UtcDateTime)); +#pragma warning disable CS0618 // Type or member is obsolete var tokenResponse = new TokenResponse { Bearer = tokenHandler.WriteToken(securityToken), ExpiresAt = expiry, }; +#pragma warning restore CS0618 // Type or member is obsolete return tokenResponse; } diff --git a/src/Tgstation.Server.Host/Server.cs b/src/Tgstation.Server.Host/Server.cs index c99506805b..e6ea8c04b1 100644 --- a/src/Tgstation.Server.Host/Server.cs +++ b/src/Tgstation.Server.Host/Server.cs @@ -128,7 +128,6 @@ namespace Tgstation.Server.Host try { using (Host = hostBuilder.Build()) - { try { logger = Host.Services.GetRequiredService>(); @@ -155,7 +154,6 @@ namespace Tgstation.Server.Host { logger = null; } - } } finally { diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 47ea588eaf..2bfbc079df 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -73,11 +73,13 @@ - + + + @@ -95,7 +97,7 @@ - + @@ -116,6 +118,8 @@ + + diff --git a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs new file mode 100644 index 0000000000..56aa288619 --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs @@ -0,0 +1,76 @@ +using System; + +using Microsoft.AspNetCore.Http; + +using Tgstation.Server.Api; + +namespace Tgstation.Server.Host.Utils +{ + /// + sealed class ApiHeadersProvider : IApiHeadersProvider + { + /// + public ApiHeaders ApiHeaders => attemptedApiHeadersCreation + ? apiHeaders + : CreateApiHeaders(false); + + /// + public HeadersException HeadersException { get; private set; } + + /// + /// The for the . + /// + readonly IHttpContextAccessor httpContextAccessor; + + /// + /// Backing field for . + /// + ApiHeaders apiHeaders; + + /// + /// If populating was previously attempted. + /// + bool attemptedApiHeadersCreation; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public ApiHeadersProvider(IHttpContextAccessor httpContextAccessor) + { + this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + } + + /// + public ApiHeaders CreateAuthlessHeaders() => CreateApiHeaders(true); + + /// + /// Attempt to parse from the , optionally populating the properties. + /// + /// If the error should be ignored and / should not be populated. + /// A newly parsed or if was set and the parse failed. + ApiHeaders CreateApiHeaders(bool authless) + { + if (httpContextAccessor.HttpContext == null) + throw new InvalidOperationException("httpContextAccessor has no HttpContext!"); + + var typedHeaders = httpContextAccessor.HttpContext.Request.GetTypedHeaders(); + if (!authless) + attemptedApiHeadersCreation = true; + + try + { + var headers = new ApiHeaders(typedHeaders, authless, !authless); + if (!authless) + apiHeaders = headers; + + return headers; + } + catch (HeadersException ex) when (!authless) + { + HeadersException = ex; + return null; + } + } + } +} diff --git a/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs new file mode 100644 index 0000000000..7de65834d3 --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs @@ -0,0 +1,28 @@ +using Tgstation.Server.Api; + +namespace Tgstation.Server.Host.Utils +{ + /// + /// Provides . + /// + public interface IApiHeadersProvider + { + /// + /// The created , if any. + /// + ApiHeaders ApiHeaders { get; } + + /// + /// The thrown when attempting to parse the if any. + /// + HeadersException HeadersException { get; } + + /// + /// Attempt to create without checking for the presence of an header. + /// + /// A new . + /// This does not populate the property. + /// Thrown if the requested contain errors other than . + ApiHeaders CreateAuthlessHeaders(); + } +} diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs new file mode 100644 index 0000000000..3892aad56c --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Utils.SignalR +{ + /// + /// An implementation of with connection ID mapping. + /// + /// The the is for. + /// The for implementing methods. + sealed class ComprehensiveHubContext : IConnectionMappedHubContext, IHubConnectionMapper + where THub : ConnectionMappingHub + where THubMethods : class + { + /// + public IHubClients Clients => wrappedHubContext.Clients; + + /// + public IGroupManager Groups => wrappedHubContext.Groups; + + /// + /// The being wrapped. + /// + readonly IHubContext wrappedHubContext; + + /// + /// The for the . + /// + readonly ILogger> logger; + + /// + /// Map of s to their associated s. + /// + readonly ConcurrentDictionary> userConnections; + + /// + public event Func, Task>, CancellationToken, ValueTask> OnConnectionMapGroups; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + public ComprehensiveHubContext( + IHubContext wrappedHubContext, + ILogger> logger) + { + this.wrappedHubContext = wrappedHubContext ?? throw new ArgumentNullException(nameof(wrappedHubContext)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + userConnections = new ConcurrentDictionary>(); + } + + /// + public List UserConnectionIds(User user) + { + ArgumentNullException.ThrowIfNull(user); + var connectionIds = userConnections.GetOrAdd(user.Id.Value, _ => new Dictionary()); + lock (connectionIds) + return connectionIds.Keys.ToList(); + } + + /// + public ValueTask UserConnected(IAuthenticationContext authenticationContext, THub hub, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(authenticationContext); + ArgumentNullException.ThrowIfNull(hub); + + var userId = authenticationContext.User.Id.Value; + var context = hub.Context; + logger.LogTrace( + "Mapping user {userId} to hub connection ID: {connectionId}", + userId, + context.ConnectionId); + + var mappingTask = OnConnectionMapGroups?.Invoke( + authenticationContext, + mappedGroups => Task.WhenAll( + mappedGroups.Select( + group => hub.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken))), + cancellationToken) + ?? ValueTask.CompletedTask; + userConnections.AddOrUpdate( + userId, + _ => new Dictionary + { + { context.ConnectionId, context }, + }, + (_, old) => + { + lock (old) + old[context.ConnectionId] = context; + + return old; + }); + + return mappingTask; + } + + /// + public void UserDisconnected(string connectionId) + { + ArgumentNullException.ThrowIfNull(connectionId); + foreach (var kvp in userConnections) + lock (kvp.Value) + if (kvp.Value.Remove(connectionId)) + logger.LogTrace("User {userId} disconnected connection ID: {connectionId}", kvp.Key, connectionId); + } + + /// + public void AbortUnauthedConnections(User user) + { + ArgumentNullException.ThrowIfNull(user); + logger.LogTrace("NotifyAndAbortUnauthedConnections. UID {userId}", user.Id.Value); + + List connections = null; + userConnections.AddOrUpdate( + user.Id.Value, + _ => new Dictionary(), + (_, old) => + { + lock (old) + { + connections = old.Values.ToList(); + old.Clear(); + } + + return old; + }); + + foreach (var context in connections) + context.Abort(); + } + } +} diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs b/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs new file mode 100644 index 0000000000..05e2eb8742 --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Utils.SignalR +{ + /// + /// Base for s that want to map their connection IDs to s. + /// + /// The child inheriting from the . + /// The for implementing methods. + [TgsAuthorize] + abstract class ConnectionMappingHub : Hub + where TChildHub : ConnectionMappingHub + where THubMethods : class + { + /// + /// The used to map connections. + /// + readonly IHubConnectionMapper connectionMapper; + + /// + /// The for the . + /// + readonly IAuthenticationContext authenticationContext; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + protected ConnectionMappingHub( + IHubConnectionMapper connectionMapper, + IAuthenticationContext authenticationContext) + { + this.connectionMapper = connectionMapper ?? throw new ArgumentNullException(nameof(connectionMapper)); + this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); + } + + /// + public override async Task OnConnectedAsync() + { + await connectionMapper.UserConnected(authenticationContext, (TChildHub)this, Context.ConnectionAborted); + await base.OnConnectedAsync(); + } + + /// + [AllowAnonymous] + public override Task OnDisconnectedAsync(Exception exception) + { + connectionMapper.UserDisconnected(Context.ConnectionId); + return base.OnDisconnectedAsync(exception); + } + } +} diff --git a/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs new file mode 100644 index 0000000000..101e8e599e --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Utils.SignalR +{ + /// + /// A that maps s to their connection IDs. + /// + /// The the is for. + /// The interface for implementing methods. + interface IConnectionMappedHubContext : IHubContext + where THub : Hub + where THubMethods : class + { + /// + /// Called when a user connects. + /// Parameters: + /// - The of the authenticated user. + /// - An accepting an of the group names the user should have and returning a that should be ed. + /// - The for the operation. + /// Returns: A representing the running operation. + /// + event Func, Task>, CancellationToken, ValueTask> OnConnectionMapGroups; + + /// + /// Gets a of current connection IDs for a given . + /// + /// The to get connection IDs for. + /// A representing the active connection IDs of the . + List UserConnectionIds(User user); + + /// + /// Aborts the connections associated with the given . + /// + /// The to abort the connections of. + void AbortUnauthedConnections(User user); + } +} diff --git a/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs b/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs new file mode 100644 index 0000000000..4521c87fcc --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; + +namespace Tgstation.Server.Host.Utils.SignalR +{ + /// + /// Handles mapping connection IDs to s for a given . + /// + /// The whose connections are being mapped. + /// The for implementing methods. + interface IHubConnectionMapper + where THub : ConnectionMappingHub + where THubMethods : class + { + /// + /// To be called when a hub connection is made. + /// + /// The associated with the connection. + /// The . + /// The for the operation. + /// A representing the running operation. + ValueTask UserConnected(IAuthenticationContext authenticationContext, THub hub, CancellationToken cancellationToken); + + /// + /// To be called when a hub connection is terminated. + /// + /// The connection ID. + void UserDisconnected(string connectionId); + } +} diff --git a/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs b/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs index 3bd803b362..89f7716732 100644 --- a/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs +++ b/src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs @@ -18,6 +18,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Utils { diff --git a/tests/DMAPI/BasicOperation/Config.dm b/tests/DMAPI/BasicOperation/Config.dm index 3cd3f332de..45d12ef665 100644 --- a/tests/DMAPI/BasicOperation/Config.dm +++ b/tests/DMAPI/BasicOperation/Config.dm @@ -1,12 +1,3 @@ -#define TGS_EXTERNAL_CONFIGURATION -#define TGS_DEFINE_AND_SET_GLOBAL(Name, Value) var/##Name = ##Value -#define TGS_READ_GLOBAL(Name) global.##Name -#define TGS_WRITE_GLOBAL(Name, Value) global.##Name = ##Value -#define TGS_PROTECT_DATUM(Path) -#define TGS_WORLD_ANNOUNCE(message) world << ##message #define TGS_INFO_LOG(message) world.log << "Info: [##message]" -#define TGS_WARNING_LOG(message) world.log << "Warn: [##message]" #define TGS_ERROR_LOG(message) world.log << "Err: [##message]" -#define TGS_NOTIFY_ADMINS(event) -#define TGS_CLIENT_COUNT 0 #define TGS_V3_API diff --git a/tests/DMAPI/LongRunning/Config.dm b/tests/DMAPI/LongRunning/Config.dm index de593ce90f..3aa6b8a01c 100644 --- a/tests/DMAPI/LongRunning/Config.dm +++ b/tests/DMAPI/LongRunning/Config.dm @@ -1,12 +1,2 @@ -#define TGS_EXTERNAL_CONFIGURATION -#define TGS_DEFINE_AND_SET_GLOBAL(Name, Value) var/##Name = ##Value -#define TGS_READ_GLOBAL(Name) global.##Name -#define TGS_WRITE_GLOBAL(Name, Value) global.##Name = ##Value -#define TGS_PROTECT_DATUM(Path) -#define TGS_WORLD_ANNOUNCE(message) world << ##message #define TGS_INFO_LOG(message) TgsInfo(##message) -#define TGS_WARNING_LOG(message) world.log << "Warn: [##message]" #define TGS_ERROR_LOG(message) TgsError(##message) -#define TGS_NOTIFY_ADMINS(event) -#define TGS_CLIENT_COUNT 0 -#define TGS_DEBUG_LOG(message) world.log << "TGS DEBUG: [##message]" diff --git a/tests/DMAPI/test_prelude.dm b/tests/DMAPI/test_prelude.dm index 18b7192ec1..efb84d46ae 100644 --- a/tests/DMAPI/test_prelude.dm +++ b/tests/DMAPI/test_prelude.dm @@ -1,3 +1,14 @@ +#define TGS_EXTERNAL_CONFIGURATION +#define TGS_DEFINE_AND_SET_GLOBAL(Name, Value) var/##Name = ##Value +#define TGS_READ_GLOBAL(Name) global.##Name +#define TGS_WRITE_GLOBAL(Name, Value) global.##Name = ##Value +#define TGS_PROTECT_DATUM(Path) +#define TGS_WORLD_ANNOUNCE(message) world << ##message +#define TGS_WARNING_LOG(message) world.log << "Warn: [##message]" +#define TGS_NOTIFY_ADMINS(event) +#define TGS_CLIENT_COUNT 0 +#define TGS_DEBUG_LOG(message) world.log << "TGS DEBUG: [##message]" + #include "..\..\src\DMAPI\tgs.dm" #include "..\..\src\DMAPI\tgs\includes.dm" #include "test_setup.dm" diff --git a/tests/Tgstation.Server.Api.Tests/Models/TestJobCode.cs b/tests/Tgstation.Server.Api.Tests/Models/TestJobCode.cs new file mode 100644 index 0000000000..0d5a020767 --- /dev/null +++ b/tests/Tgstation.Server.Api.Tests/Models/TestJobCode.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Tgstation.Server.Api.Models.Tests +{ + [TestClass] + public sealed class TestJobCode + { + [TestMethod] + public void TestAllCodesHaveDescription() + { + var jobCodeType = typeof(JobCode); + foreach (var code in Enum.GetValues(typeof(JobCode)).Cast()) + Assert.IsFalse( + String.IsNullOrWhiteSpace( + jobCodeType + .GetField(code.ToString()) + .GetCustomAttributes(false) + .OfType() + .FirstOrDefault() + ?.Description), + $"JobCode {code} is missing a description!"); + } + } +} diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index e53a4f8e1a..8d6808f1b2 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -6,6 +6,7 @@ using System.Net.Http.Headers; using System.Net.Mime; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Api.Tests { @@ -22,7 +23,7 @@ namespace Tgstation.Server.Api.Tests { Assert.ThrowsException(() => new ApiHeaders(null, null)); Assert.ThrowsException(() => new ApiHeaders(productHeaderValue, null)); - var headers = new ApiHeaders(productHeaderValue, String.Empty); + var headers = new ApiHeaders(productHeaderValue, new TokenResponse { Bearer = String.Empty }); headers = new ApiHeaders(productHeaderValue, String.Empty, OAuthProvider.GitHub); } @@ -38,11 +39,11 @@ namespace Tgstation.Server.Api.Tests { { "Accept", MediaTypeNames.Application.Json }, { "Api", "Tgstation.Server.Api/4.0.0.0" }, - { "Authorization", "Bearer asdfasdf" }, + { "Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJuYmYiOjEyMzR9.0CsmEwXt9oNTDisikbZ-MUr1eXSMKD8YKdZIOwMeLoc" }, // fake, but we need a valid token to avoid errors { "User-Agent", userAgent } }; - return new ApiHeaders(new RequestHeaders(headers)); + return new ApiHeaders(new RequestHeaders(headers), false, false); }; var header = TestHeader(BrowserHeader); diff --git a/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs b/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs index efd4bccc29..42f90a47bc 100644 --- a/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs +++ b/tests/Tgstation.Server.Client.Tests/Components/TestDreamDaemonClient.cs @@ -1,13 +1,12 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; -using System; -using System.Collections.Generic; -using System.Text; +using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Moq; + using Tgstation.Server.Api; -using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; namespace Tgstation.Server.Client.Components.Tests diff --git a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs index cfbd9be15e..900d64a56f 100644 --- a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs +++ b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs @@ -47,7 +47,17 @@ namespace Tgstation.Server.Client.Tests var httpClient = new Mock(); httpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(response)); - var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"), null, false); + var client = new ApiClient( + httpClient.Object, + new Uri("http://fake.com"), + new ApiHeaders( + new ProductHeaderValue("fake"), + new TokenResponse + { + Bearer = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMyIsImV4cCI6IjE2OTkzOTUwNTIiLCJuYmYiOiIxNjk5MzA4NjUyIiwiaXNzIjoiVGdzdGF0aW9uLlNlcnZlci5Ib3N0IiwiYXVkIjoiVGdzdGF0aW9uLlNlcnZlci5BcGkifQ.GRqEd3LRYLkbzk7NHTqcBPX-Xc1vmE_zmbJEDowAXV4", + }), + null, + false); var result = await client.Read(Routes.Engine, default); Assert.AreEqual(sample.EngineVersion, result.EngineVersion); @@ -77,7 +87,17 @@ namespace Tgstation.Server.Client.Tests var httpClient = new Mock(); httpClient.Setup(x => x.SendAsync(It.IsNotNull(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(response)); - var client = new ApiClient(httpClient.Object, new Uri("http://fake.com"), new ApiHeaders(new ProductHeaderValue("fake"), "fake"), null, true); + var client = new ApiClient( + httpClient.Object, + new Uri("http://fake.com"), + new ApiHeaders( + new ProductHeaderValue("fake"), + new TokenResponse + { + Bearer = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMyIsImV4cCI6IjE2OTkzOTUwNTIiLCJuYmYiOiIxNjk5MzA4NjUyIiwiaXNzIjoiVGdzdGF0aW9uLlNlcnZlci5Ib3N0IiwiYXVkIjoiVGdzdGF0aW9uLlNlcnZlci5BcGkifQ.GRqEd3LRYLkbzk7NHTqcBPX-Xc1vmE_zmbJEDowAXV4" + }), + null, + false); await Assert.ThrowsExceptionAsync(() => client.Read(Routes.Engine, default).AsTask()); } diff --git a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj index b4041bd259..c058c00b84 100644 --- a/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj +++ b/tests/Tgstation.Server.Client.Tests/Tgstation.Server.Client.Tests.csproj @@ -5,6 +5,10 @@ $(TgsFrameworkVersion) + + + + diff --git a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs index e1f604a533..855870d4c8 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs +++ b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs @@ -35,7 +35,7 @@ namespace Tgstation.Server.Host.Service.Tests var mockWatchdog = new Mock(); var args = Array.Empty(); CancellationToken cancellationToken = default; - ValueTask? signalCheckerTask = null; + Task signalCheckerTask = null; var childStarted = false; ISignalChecker signalChecker = null; @@ -46,8 +46,8 @@ namespace Tgstation.Server.Host.Service.Tests { childStarted = true; return (123, Task.CompletedTask); - }, cancellationToken); - }).Returns(ValueTask.FromResult(true)).Verifiable(); + }, cancellationToken).AsTask(); + }).ReturnsAsync(true).Verifiable(); var mockWatchdogFactory = new Mock(); mockWatchdogFactory.Setup(x => x.CreateWatchdog(It.IsNotNull(), It.IsNotNull())) @@ -67,7 +67,7 @@ namespace Tgstation.Server.Host.Service.Tests } mockWatchdogFactory.VerifyAll(); - Assert.IsTrue(signalCheckerTask.Value.IsCompleted); + Assert.IsTrue(signalCheckerTask.IsCompleted); } } } diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs index 17a1f33211..1e0d7b7fa8 100644 --- a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -16,7 +16,7 @@ namespace Tgstation.Server.Host.Security.Tests [TestMethod] public void TestConstruction() { - Assert.ThrowsException(() => new AuthenticationContext(null, null, null)); + Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, null, null)); var mockSystemIdentity = new Mock(); var user = new User() @@ -24,18 +24,18 @@ namespace Tgstation.Server.Host.Security.Tests PermissionSet = new PermissionSet() }; - var authContext = new AuthenticationContext(null, user, null); - Assert.ThrowsException(() => new AuthenticationContext(mockSystemIdentity.Object, null, null)); + var authContext = new AuthenticationContext(); + Assert.ThrowsException(() => new AuthenticationContext().Initialize(mockSystemIdentity.Object, null, null)); var instanceUser = new InstancePermissionSet(); - 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); + Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, null, instanceUser)); + Assert.ThrowsException(() => new AuthenticationContext().Initialize(mockSystemIdentity.Object, null, instanceUser)); + new AuthenticationContext().Initialize(mockSystemIdentity.Object, user, null); + new AuthenticationContext().Initialize(null, user, instanceUser); + new AuthenticationContext().Initialize(mockSystemIdentity.Object, user, instanceUser); user.SystemIdentifier = "root"; - Assert.ThrowsException(() => new AuthenticationContext(null, user, null)); + Assert.ThrowsException(() => new AuthenticationContext().Initialize(null, user, null)); } @@ -47,7 +47,8 @@ namespace Tgstation.Server.Host.Security.Tests PermissionSet = new PermissionSet() }; var instanceUser = new InstancePermissionSet(); - var authContext = new AuthenticationContext(null, user, instanceUser); + var authContext = new AuthenticationContext(); + authContext.Initialize(null, user, instanceUser); user.PermissionSet.AdministrationRights = AdministrationRights.WriteUsers; instanceUser.EngineRights = EngineRights.InstallOfficialOrChangeActiveByondVersion | EngineRights.ReadActive; diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs b/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs index 167c058c19..df37c87ca0 100644 --- a/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs +++ b/tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs @@ -24,6 +24,7 @@ using Newtonsoft.Json; using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers; +using Tgstation.Server.Host.Controllers.Results; using Tgstation.Server.Host.Transfer; namespace Tgstation.Server.Host.Swarm.Tests diff --git a/tests/Tgstation.Server.Tests/Live/HardFailLogger.cs b/tests/Tgstation.Server.Tests/Live/HardFailLogger.cs index f5b5707bf7..eca7cf4ef5 100644 --- a/tests/Tgstation.Server.Tests/Live/HardFailLogger.cs +++ b/tests/Tgstation.Server.Tests/Live/HardFailLogger.cs @@ -30,7 +30,6 @@ namespace Tgstation.Server.Tests.Live && !(logMessage.StartsWith("An exception occurred in the database while saving changes for context type") && (exception is OperationCanceledException || exception?.InnerException is OperationCanceledException))) || (logLevel == LogLevel.Critical && logMessage != "DropDatabase configuration option set! Dropping any existing database...")) { - Console.WriteLine("UNEXPECTED ERROR OCCURS NEAR HERE"); failureSink(new AssertFailedException("TGS logged an unexpected error!")); } } diff --git a/tests/Tgstation.Server.Tests/Live/HardFailLoggerProvider.cs b/tests/Tgstation.Server.Tests/Live/HardFailLoggerProvider.cs index 59a677abd8..9f8ae24a05 100644 --- a/tests/Tgstation.Server.Tests/Live/HardFailLoggerProvider.cs +++ b/tests/Tgstation.Server.Tests/Live/HardFailLoggerProvider.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -15,7 +16,10 @@ namespace Tgstation.Server.Tests.Live public ILogger CreateLogger(string categoryName) => new HardFailLogger(ex => { if (!BlockFails) + { + Console.WriteLine("UNEXPECTED ERROR OCCURS NEAR HERE"); failureSink.TrySetException(ex); + } }); public void Dispose() { } diff --git a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs index 4601babae0..50a2d9f116 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs @@ -210,13 +210,10 @@ namespace Tgstation.Server.Tests.Live.Instance await chatRequest; await Task.Yield(); - var jobs = await instanceClient.Jobs.List(null, cancellationToken); - var theJobWeWant = jobs.First(x => x.Description.Contains("Reconnect chat bot")); await Task.WhenAll( jrt.WaitForJob(installJob2.InstallJob, EngineTest.EngineInstallationTimeout(compatVersion) + 30, false, null, cancellationToken), jrt.WaitForJob(cloneRequest.Result.ActiveJob, 60, false, null, cancellationToken), - jrt.WaitForJob(theJobWeWant, 30, false, null, cancellationToken), dmUpdateRequest.AsTask(), cloneRequest); @@ -231,6 +228,12 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(1, activeVersion.EngineVersion.CustomIteration); } + var jobs = await instanceClient.Jobs.List(null, cancellationToken); + var theJobWeWant = jobs + .OrderByDescending(x => x.StartedAt) + .First(x => x.Description.Contains("Reconnect chat bot")); + await jrt.WaitForJob(theJobWeWant, 30, false, null, cancellationToken); + var configSetupTask = new ConfigurationTest(instanceClient.Configuration, instanceClient.Metadata).SetupDMApiTests(true, cancellationToken); if (TestingUtils.RunningInGitHubActions diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs new file mode 100644 index 0000000000..bf683503c1 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Tgstation.Server.Api.Hubs; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Request; +using Tgstation.Server.Api.Models.Response; +using Tgstation.Server.Client; +using Tgstation.Server.Common.Extensions; + +namespace Tgstation.Server.Tests.Live.Instance +{ + sealed class JobsHubTests : IJobsHub + { + readonly IServerClient permedUser; + readonly IServerClient permlessUser; + + readonly TaskCompletionSource finishTcs; + + readonly ConcurrentDictionary seenJobs; + + readonly HashSet permlessSeenJobs; + + HubConnection conn1, conn2; + bool permlessIsPermed; + + long? permlessPsId; + + public JobsHubTests(IServerClient permedUser, IServerClient permlessUser) + { + this.permedUser = permedUser; + this.permlessUser = permlessUser; + + Assert.AreNotSame(permedUser, permlessUser); + + finishTcs = new TaskCompletionSource(); + + seenJobs = new ConcurrentDictionary(); + permlessSeenJobs = new HashSet(); + } + + public Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken) + { + try + { + Assert.IsTrue(job.InstanceId.HasValue); + Assert.IsNotNull(job.StartedBy); + Assert.IsTrue(job.StartedBy.Id.HasValue); + Assert.IsTrue(job.StartedAt.HasValue); + Assert.IsNotNull(job.Description); + + seenJobs.AddOrUpdate(job.Id.Value, job, (_, old) => + { + Assert.IsFalse(old.StoppedAt.HasValue, $"Received update for job {job.Id} after it had completed!"); + + return job; + }); + } + catch(Exception ex) + { + finishTcs.SetException(ex); + } + + return Task.CompletedTask; + } + + + class ShouldNeverReceiveUpdates : IJobsHub + { + public Action Callback { get; set; } + + public Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken) + { + Callback(job); + return Task.CompletedTask; + } + } + + public async Task Run(CancellationToken cancellationToken) + { + var neverReceiver = new ShouldNeverReceiveUpdates() + { + Callback = job => + { + if (!permlessIsPermed) + finishTcs.TrySetException(new Exception($"ShouldNeverReceiveUpdates received an update for job {job.Id}!")); + else + lock (permlessSeenJobs) + permlessSeenJobs.Add(job.Id.Value); + }, + }; + + await using (conn1 = (HubConnection)await permedUser.SubscribeToJobUpdates( + this, + null, + null, + cancellationToken)) + await using (conn2 = (HubConnection)await permlessUser.SubscribeToJobUpdates( + neverReceiver, + null, + null, + cancellationToken)) + { + Console.WriteLine($"Initial conn1: {conn1.ConnectionId}"); + Console.WriteLine($"Initial conn2: {conn2.ConnectionId}"); + + conn1.Reconnected += (newId) => + { + Console.WriteLine($"conn1 reconnected: {newId}"); + return Task.CompletedTask; + }; + conn2.Reconnected += (newId) => + { + Console.WriteLine($"conn1 reconnected: {newId}"); + return Task.CompletedTask; + }; + + await finishTcs.Task; + } + + var allInstances = await permedUser.Instances.List(null, cancellationToken); + + async ValueTask> CheckInstance(InstanceResponse instance) + { + var wasOffline = !instance.Online.Value; + if (wasOffline) + await permedUser.Instances.Update(new InstanceUpdateRequest + { + Id = instance.Id, + Online = true, + }, cancellationToken); + + var jobs = await permedUser.Instances.CreateClient(instance).Jobs.List(null, cancellationToken); + if (wasOffline) + await permedUser.Instances.Update(new InstanceUpdateRequest + { + Id = instance.Id, + Online = false, + }, cancellationToken); + + return jobs; + } + + var allJobsTask = allInstances + .Select(CheckInstance); + + var allJobs = (await ValueTaskExtensions.WhenAll(allJobsTask, allInstances.Count)).SelectMany(x => x).ToList(); + var missableMissedJobs = 0; + foreach (var job in allJobs) + { + var seenThisJob = seenJobs.TryGetValue(job.Id.Value, out var hubJob); + if (seenThisJob) + { + Assert.AreEqual(job.StoppedAt, hubJob.StoppedAt); + Assert.AreEqual(job.InstanceId, hubJob.InstanceId); + Assert.AreEqual(job.ExceptionDetails, hubJob.ExceptionDetails); + Assert.AreEqual(job.Stage, hubJob.Stage); + Assert.AreEqual(job.CancelledBy?.Id, hubJob.CancelledBy?.Id); + Assert.AreEqual(job.Cancelled, hubJob.Cancelled); + Assert.AreEqual(job.StartedBy?.Id, hubJob.StartedBy?.Id); + Assert.AreEqual(job.CancelRight, hubJob.CancelRight); + Assert.AreEqual(job.CancelRightsType, hubJob.CancelRightsType); + Assert.AreEqual(job.Progress, hubJob.Progress); + Assert.AreEqual(job.Description, hubJob.Description); + Assert.AreEqual(job.ErrorCode, hubJob.ErrorCode); + Assert.AreEqual(job.StartedAt, hubJob.StartedAt); + } + else + { + var wasMissableJob = job.JobCode == JobCode.ReconnectChatBot + || job.JobCode == JobCode.StartupWatchdogLaunch + || job.JobCode == JobCode.StartupWatchdogReattach; + Assert.IsTrue(wasMissableJob); + ++missableMissedJobs; + } + } + + // some instances may be detached, but our cache remains + var accountedJobs = allJobs.Count - missableMissedJobs; + var accountedSeenJobs = seenJobs.Where(x => allInstances.Any(i => i.Id.Value == x.Value.InstanceId)).Count(); + Assert.AreEqual(accountedJobs, accountedSeenJobs); + Assert.IsTrue(accountedJobs <= seenJobs.Count); + Assert.AreNotEqual(0, permlessSeenJobs.Count); + Assert.IsTrue(permlessSeenJobs.Count < seenJobs.Count); + Assert.IsTrue(permlessSeenJobs.All(id => seenJobs.ContainsKey(id))); + + await using var conn3 = (HubConnection)await permedUser.SubscribeToJobUpdates( + this, + null, + null, + cancellationToken); + + Assert.AreEqual(HubConnectionState.Connected, conn3.State); + await permlessUser.DisposeAsync(); + await permedUser.DisposeAsync(); + } + + public void ExpectShutdown() + { + Assert.AreEqual(HubConnectionState.Connected, conn1.State); + Assert.AreEqual(HubConnectionState.Connected, conn2.State); + } + + public async ValueTask WaitForReconnect(CancellationToken cancellationToken) + { + await Task.WhenAll(conn1.StopAsync(cancellationToken), conn2.StopAsync(cancellationToken)); + + Assert.AreEqual(HubConnectionState.Disconnected, conn1.State); + Assert.AreEqual(HubConnectionState.Disconnected, conn2.State); + + // force token refreshs + await Task.WhenAll(permedUser.Administration.Read(cancellationToken).AsTask(), permlessUser.Instances.List(null, cancellationToken).AsTask()); + + await Task.WhenAll(conn1.StartAsync(cancellationToken), conn2.StartAsync(cancellationToken)); + + Assert.AreEqual(HubConnectionState.Connected, conn1.State); + Assert.AreEqual(HubConnectionState.Connected, conn2.State); + Console.WriteLine($"New conn1: {conn1.ConnectionId}"); + Console.WriteLine($"New conn2: {conn2.ConnectionId}"); + + if (!permlessPsId.HasValue) + { + var permlessUserId = long.Parse(permlessUser.Token.ParseJwt().Subject); + permlessPsId = (await permedUser.Users.GetId(new Api.Models.EntityId + { + Id = permlessUserId + }, cancellationToken)).PermissionSet.Id; + } + + var instancesTask = permedUser.Instances.List(null, cancellationToken); + + permlessIsPermed = !permlessIsPermed; + + var instances = await instancesTask; + await ValueTaskExtensions.WhenAll( + instances + .Where(instance => instance.Online.Value) + .Select(async instance => + { + var ic = permedUser.Instances.CreateClient(instance); + if (permlessIsPermed) + await ic.PermissionSets.Create(new InstancePermissionSetRequest + { + PermissionSetId = permlessPsId.Value, + }, cancellationToken); + else + await ic.PermissionSets.Delete(new InstancePermissionSetRequest + { + PermissionSetId = permlessPsId.Value + }, cancellationToken); + })); + } + + public void CompleteNow() => finishTcs.TrySetResult(); + } +} diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs index 8de482a917..21a8c873d3 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs @@ -21,11 +21,15 @@ namespace Tgstation.Server.Tests.Live.Instance public async Task WaitForJob(JobResponse originalJob, int timeout, bool? expectFailure, ErrorCode? expectedCode, CancellationToken cancellationToken) { + Assert.IsNotNull(originalJob.Id); + Assert.IsNotNull(originalJob.JobCode); var job = originalJob; do { await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); job = await JobsClient.GetId(job, cancellationToken); + Assert.IsNotNull(job.Id); + Assert.IsNotNull(job.JobCode); --timeout; } while (!job.StoppedAt.HasValue && timeout > 0); @@ -48,11 +52,15 @@ namespace Tgstation.Server.Tests.Live.Instance protected async Task WaitForJobProgress(JobResponse originalJob, int timeout, CancellationToken cancellationToken) { + Assert.IsNotNull(originalJob.Id); + Assert.IsNotNull(originalJob.JobCode); var job = originalJob; do { await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); job = await JobsClient.GetId(job, cancellationToken); + Assert.IsNotNull(job.Id); + Assert.IsNotNull(job.JobCode); --timeout; } while (!job.Progress.HasValue && job.Stage == null && timeout > 0); @@ -65,6 +73,8 @@ namespace Tgstation.Server.Tests.Live.Instance protected async Task WaitForJobProgressThenCancel(JobResponse originalJob, int timeout, CancellationToken cancellationToken) { + Assert.IsNotNull(originalJob.Id); + Assert.IsNotNull(originalJob.JobCode); var start = DateTimeOffset.UtcNow; var job = await WaitForJobProgress(originalJob, timeout, cancellationToken); diff --git a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs index 0d27ba1c57..473861045e 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs @@ -195,7 +195,7 @@ namespace Tgstation.Server.Tests.Live.Instance // reimplement TellWorldToReboot because it expects a new deployment and we don't care System.Console.WriteLine("TEST: Hack world reboot topic..."); - var result = await topicClient.SendTopic(IPAddress.Loopback, "tgs_integration_test_special_tactics=1", ddPort, cancellationToken); + var result = await topicClient.SendTopic(IPAddress.Loopback, "tgs_integration_test_special_tactics=1", FindTopicPort(), cancellationToken); Assert.AreEqual("ack", result.StringData); using var tempCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -1164,16 +1164,6 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(initialStatus.ActiveCompileJob.Id, daemonStatus.ActiveCompileJob.Id); var newerCompileJob = daemonStatus.StagedCompileJob; Assert.AreNotEqual(daemonStatus.ActiveCompileJob.EngineVersion, newerCompileJob.EngineVersion); - if (testVersion.Engine.Value == EngineType.Byond) -#pragma warning disable CS0618 // Type or member is obsolete - Assert.AreEqual( - new Version( - daemonStatus.ActiveCompileJob.EngineVersion.Version.Major, - daemonStatus.ActiveCompileJob.EngineVersion.Version.Minor, - daemonStatus.ActiveCompileJob.EngineVersion.CustomIteration ?? 0) - .ToString(), - daemonStatus.ActiveCompileJob.ByondVersion.ToString()); -#pragma warning restore CS0618 // Type or member is obsolete Assert.AreEqual(versionToInstall, newerCompileJob.EngineVersion); diff --git a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs index 515180c049..a487a91991 100644 --- a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs +++ b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClient.cs @@ -13,8 +13,18 @@ namespace Tgstation.Server.Tests.Live { sealed class RateLimitRetryingApiClient : ApiClient { - public RateLimitRetryingApiClient(IHttpClient httpClient, Uri url, ApiHeaders apiHeaders, ApiHeaders tokenRefreshHeaders, bool authless) - : base(httpClient, url, apiHeaders, tokenRefreshHeaders, authless) + public RateLimitRetryingApiClient( + IHttpClient httpClient, + Uri url, + ApiHeaders apiHeaders, + ApiHeaders tokenRefreshHeaders, + bool authless) + : base( + httpClient, + url, + apiHeaders, + tokenRefreshHeaders, + authless) { } diff --git a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs index 7edca57797..f9ba45e66e 100644 --- a/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs +++ b/tests/Tgstation.Server.Tests/Live/RateLimitRetryingApiClientFactory.cs @@ -1,6 +1,7 @@ using System; using Tgstation.Server.Api; +using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client; using Tgstation.Server.Common.Http; @@ -8,7 +9,11 @@ namespace Tgstation.Server.Tests.Live { sealed class RateLimitRetryingApiClientFactory : IApiClientFactory { - public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders tokenRefreshHeaders, bool authless) + public IApiClient CreateApiClient( + Uri url, + ApiHeaders apiHeaders, + ApiHeaders tokenRefreshHeaders, + bool authless) => new RateLimitRetryingApiClient( new HttpClient(), url, diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 1845ee9eff..fbf5fe44e5 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -5,15 +5,25 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; +using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + using Tgstation.Server.Api; +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Request; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Client; +using Tgstation.Server.Client.Extensions; using Tgstation.Server.Common.Extensions; using Tgstation.Server.Host; @@ -200,14 +210,17 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), serverInfo.WindowsHost); //check that modifying the token even slightly fucks up the auth +#pragma warning disable CS0618 // Type or member is obsolete var newToken = new TokenResponse { ExpiresAt = serverClient.Token.ExpiresAt, Bearer = serverClient.Token.Bearer + '0' }; +#pragma warning restore CS0618 // Type or member is obsolete var badClient = clientFactory.CreateFromToken(serverClient.Url, newToken); await ApiAssert.ThrowsException(() => badClient.Administration.Read(cancellationToken)); + await ApiAssert.ThrowsException(() => badClient.ServerInformation(cancellationToken)); } static async Task TestOAuthFails(IServerClient serverClient, CancellationToken cancellationToken) @@ -343,12 +356,117 @@ namespace Tgstation.Server.Tests.Live } } + class FuncProxiedJobsHub : IJobsHub + { + public Func ProxyFunc { get; set; } + + public Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken) + => ProxyFunc(job, cancellationToken); + } + + static async Task TestSignalRUsage(IServerClientFactory serverClientFactory, IServerClient serverClient, CancellationToken cancellationToken) + { + // test regular creation works without error + var hubConnectionBuilder = new HubConnectionBuilder(); + + var tokenRetrivalFunc = () => Task.FromResult("FakeToken"); + + hubConnectionBuilder.WithUrl( + new Uri(serverClient.Url, Routes.JobsHub), + HttpTransportType.ServerSentEvents, + options => + { + options.AccessTokenProvider = () => tokenRetrivalFunc(); + ((IApiClient)typeof(ServerClient) + .GetField( + "apiClient", + BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(serverClient)) + .Headers + .SetHubConnectionHeaders(options.Headers); + }) + .AddNewtonsoftJsonProtocol(options => + { + // we can get away without setting the serializer settings here + }); + + hubConnectionBuilder.ConfigureLogging( + loggingBuilder => + { + loggingBuilder.SetMinimumLevel(LogLevel.Trace); + loggingBuilder.AddConsole(); + loggingBuilder + .Services + .TryAddEnumerable( + ServiceDescriptor.Singleton()); + }); + + var proxy = new FuncProxiedJobsHub(); + HubConnection hubConnection; + HardFailLoggerProvider.BlockFails = true; + try + { + await using (hubConnection = hubConnectionBuilder.Build()) + { + Assert.AreEqual(HubConnectionState.Disconnected, hubConnection.State); + hubConnection.ProxyOn(proxy); + + var exception = await Assert.ThrowsExceptionAsync(() => hubConnection.StartAsync(cancellationToken)); + + Assert.AreEqual(HttpStatusCode.Unauthorized, exception.StatusCode); + Assert.AreEqual(HubConnectionState.Disconnected, hubConnection.State); + + tokenRetrivalFunc = () => Task.FromResult(serverClient.Token.Bearer); + await hubConnection.StartAsync(cancellationToken); + + Assert.AreEqual(HubConnectionState.Connected, hubConnection.State); + } + + Assert.AreEqual(HubConnectionState.Disconnected, hubConnection.State); + + var createRequest = new UserCreateRequest + { + Enabled = true, + Name = "SignalRTestUser", + Password = "asdfasdfasdfasdfasdf" + }; + + var testUser = await serverClient.Users.Create(createRequest, cancellationToken); + await using var testUserClient = await serverClientFactory.CreateFromLogin(serverClient.Url, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); + await using var testUserConn1 = (HubConnection)await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); + + await serverClient.Users.Update(new UserUpdateRequest + { + Id = testUser.Id, + Enabled = false, + }, cancellationToken); + + // need a second here + for (var i = 0; i < 10 && testUserConn1.State == HubConnectionState.Connected; ++i) + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + + Assert.AreNotEqual(HubConnectionState.Connected, testUserConn1.State); + + await using var testUserConn2 = (HubConnection)await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); + + for (var i = 0; i < 10 && testUserConn2.State == HubConnectionState.Connected; ++i) + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + + Assert.AreNotEqual(HubConnectionState.Connected, testUserConn2.State); + } + finally + { + HardFailLoggerProvider.BlockFails = false; + } + } + public static Task Run(IServerClientFactory clientFactory, IServerClient serverClient, CancellationToken cancellationToken) => Task.WhenAll( TestRequestValidation(serverClient, cancellationToken), TestOAuthFails(serverClient, cancellationToken), TestServerInformation(clientFactory, serverClient, cancellationToken), TestInvalidTransfers(serverClient, cancellationToken), - RegressionTestForLeakedPasswordHashesBug(serverClient, cancellationToken)); + RegressionTestForLeakedPasswordHashesBug(serverClient, cancellationToken), + TestSignalRUsage(clientFactory, serverClient, cancellationToken)); } } diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 196b492410..faf694db0e 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -291,7 +291,7 @@ namespace Tgstation.Server.Tests.Live return await action(); } - using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + await using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { // Disabled OAuth test using (var httpClient = new HttpClient()) @@ -372,7 +372,7 @@ namespace Tgstation.Server.Tests.Live await new Host.IO.DefaultIOManager().DeleteDirectory(server.UpdatePath, cancellationToken); serverTask = server.Run(cancellationToken).AsTask(); - using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + await using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { // test we can't do this without the correct permission @@ -428,7 +428,7 @@ namespace Tgstation.Server.Tests.Live try { var testUpdateVersion = new Version(5, 11, 20); - using var adminClient = await CreateAdminClient(server.Url, cancellationToken); + await using var adminClient = await CreateAdminClient(server.Url, cancellationToken); await ApiAssert.ThrowsException( () => adminClient.Administration.Update( new ServerUpdateRequest @@ -478,7 +478,7 @@ namespace Tgstation.Server.Tests.Live try { - using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); + await using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); var controllerInfo = await controllerClient.ServerInformation(cancellationToken); @@ -580,9 +580,9 @@ namespace Tgstation.Server.Tests.Live try { - using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); - using var node1Client = await CreateAdminClient(node1.Url, cancellationToken); - using var node2Client = await CreateAdminClient(node2.Url, cancellationToken); + await using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); + await using var node1Client = await CreateAdminClient(node1.Url, cancellationToken); + await using var node2Client = await CreateAdminClient(node2.Url, cancellationToken); var controllerInfo = await controllerClient.ServerInformation(cancellationToken); @@ -646,12 +646,12 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(newUser.Name, node1User.Name); Assert.AreEqual(newUser.Enabled, node1User.Enabled); - using var controllerUserClient = await clientFactory.CreateFromLogin( + await using var controllerUserClient = await clientFactory.CreateFromLogin( controllerAddress, newUser.Name, "asdfasdfasdfasdf"); - using var node1BadClient = clientFactory.CreateFromToken(node1.Url, controllerUserClient.Token); + await using var node1BadClient = clientFactory.CreateFromToken(node1.Url, controllerUserClient.Token); await ApiAssert.ThrowsException(() => node1BadClient.Administration.Read(cancellationToken)); // check instance info is not shared @@ -721,8 +721,8 @@ namespace Tgstation.Server.Tests.Live controller.Run(cancellationToken).AsTask(), node1.Run(cancellationToken).AsTask()); - using var controllerClient2 = await CreateAdminClient(controller.Url, cancellationToken); - using var node1Client2 = await CreateAdminClient(node1.Url, cancellationToken); + await using var controllerClient2 = await CreateAdminClient(controller.Url, cancellationToken); + await using var node1Client2 = await CreateAdminClient(node1.Url, cancellationToken); await ApiAssert.ThrowsException(() => controllerClient2.Administration.Update( new ServerUpdateRequest @@ -737,7 +737,7 @@ namespace Tgstation.Server.Tests.Live serverTask, node2.Run(cancellationToken).AsTask()); - using var node2Client2 = await CreateAdminClient(node2.Url, cancellationToken); + await using var node2Client2 = await CreateAdminClient(node2.Url, cancellationToken); async Task WaitForSwarmServerUpdate2() { @@ -851,9 +851,9 @@ namespace Tgstation.Server.Tests.Live try { - using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); - using var node1Client = await CreateAdminClient(node1.Url, cancellationToken); - using var node2Client = await CreateAdminClient(node2.Url, cancellationToken); + await using var controllerClient = await CreateAdminClient(controller.Url, cancellationToken); + await using var node1Client = await CreateAdminClient(node1.Url, cancellationToken); + await using var node2Client = await CreateAdminClient(node2.Url, cancellationToken); var controllerInfo = await controllerClient.ServerInformation(cancellationToken); @@ -933,7 +933,7 @@ namespace Tgstation.Server.Tests.Live Assert.IsTrue(controllerTask.IsCompleted); controllerTask = controller.Run(cancellationToken).AsTask(); - using var controllerClient2 = await CreateAdminClient(controller.Url, cancellationToken); + await using var controllerClient2 = await CreateAdminClient(controller.Url, cancellationToken); // node 2 should reconnect once it's health check triggers await Task.WhenAny( @@ -970,7 +970,7 @@ namespace Tgstation.Server.Tests.Live ErrorCode.SwarmIntegrityCheckFailed); node2Task = node2.Run(cancellationToken).AsTask(); - using var node2Client2 = await CreateAdminClient(node2.Url, cancellationToken); + await using var node2Client2 = await CreateAdminClient(node2.Url, cancellationToken); // should re-register await Task.WhenAny( @@ -1027,7 +1027,7 @@ namespace Tgstation.Server.Tests.Live var serverTask = server.Run(cancellationToken); try { - using var adminClient = await CreateAdminClient(server.Url, cancellationToken); + await using var adminClient = await CreateAdminClient(server.Url, cancellationToken); var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory); var instance = await instanceManagerTest.CreateTestInstance("TgTestInstance", cancellationToken); @@ -1320,7 +1320,29 @@ namespace Tgstation.Server.Tests.Live { Api.Models.Instance instance; long initialStaged, initialActive; - using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + await using var firstAdminClient = await CreateAdminClient(server.Url, cancellationToken); + + async ValueTask CreateUserWithNoInstancePerms() + { + var createRequest = new UserCreateRequest() + { + Name = "SomePermlessChum", + Password = "alidfjuwh84322r4yrkajhfdqh38hrfiouw4", + Enabled = true, + PermissionSet = new PermissionSet + { + InstanceManagerRights = InstanceManagerRights.Read, + } + }; + + var user = await firstAdminClient.Users.Create(createRequest, cancellationToken); + Assert.IsTrue(user.Enabled); + + return await clientFactory.CreateFromLogin(server.Url, createRequest.Name, createRequest.Password, cancellationToken: cancellationToken); + } + + var jobsHubTest = new JobsHubTests(firstAdminClient, await CreateUserWithNoInstancePerms()); + Task jobsHubTestTask; { if (server.DumpOpenApiSpecpath) { @@ -1349,10 +1371,12 @@ namespace Tgstation.Server.Tests.Live } } - var rootTest = FailFast(RawRequestTests.Run(clientFactory, adminClient, cancellationToken)); - var adminTest = FailFast(new AdministrationTest(adminClient.Administration).Run(cancellationToken)); - var usersTest = FailFast(new UsersTest(adminClient).Run(cancellationToken)); - var instanceManagerTest = new InstanceManagerTest(adminClient, server.Directory); + var rootTest = FailFast(RawRequestTests.Run(clientFactory, firstAdminClient, cancellationToken)); + var adminTest = FailFast(new AdministrationTest(firstAdminClient.Administration).Run(cancellationToken)); + var usersTest = FailFast(new UsersTest(firstAdminClient).Run(cancellationToken)); + + jobsHubTestTask = FailFast(jobsHubTest.Run(cancellationToken)); + var instanceManagerTest = new InstanceManagerTest(firstAdminClient, server.Directory); var compatInstanceTask = instanceManagerTest.CreateTestInstance("CompatTestsInstance", cancellationToken); var odInstanceTask = instanceManagerTest.CreateTestInstance("OdTestsInstance", cancellationToken); var byondApiCompatInstanceTask = instanceManagerTest.CreateTestInstance("BCAPITestsInstance", cancellationToken); @@ -1362,12 +1386,12 @@ namespace Tgstation.Server.Tests.Live var byondApiCompatInstance = await byondApiCompatInstanceTask; var instancesTest = FailFast(instanceManagerTest.RunPreTest(cancellationToken)); Assert.IsTrue(Directory.Exists(instance.Path)); - var instanceClient = adminClient.Instances.CreateClient(instance); + var instanceClient = firstAdminClient.Instances.CreateClient(instance); Assert.IsTrue(Directory.Exists(instanceClient.Metadata.Path)); var instanceTest = new InstanceTest( - adminClient.Instances, + firstAdminClient.Instances, fileDownloader, GetInstanceManager(), (ushort)server.Url.Port); @@ -1394,7 +1418,7 @@ namespace Tgstation.Server.Tests.Live await instanceTest .RunCompatTests( await edgeODVersionTask, - adminClient.Instances.CreateClient(odInstance), + firstAdminClient.Instances.CreateClient(odInstance), odDMPort, odDDPort, server.HighPriorityDreamDaemon, @@ -1417,7 +1441,7 @@ namespace Tgstation.Server.Tests.Live ? new Version(510, 1346) : new Version(512, 1451) // http://www.byond.com/forum/?forum=5&command=search&scope=local&text=resolved%3a512.1451 }, - adminClient.Instances.CreateClient(compatInstance), + firstAdminClient.Instances.CreateClient(compatInstance), compatDMPort, compatDDPort, server.HighPriorityDreamDaemon, @@ -1453,7 +1477,8 @@ namespace Tgstation.Server.Tests.Live initialActive = dd.ActiveCompileJob.Id.Value; initialStaged = dd.StagedCompileJob.Id.Value; - await adminClient.Administration.Restart(cancellationToken); + jobsHubTest.ExpectShutdown(); + await firstAdminClient.Administration.Restart(cancellationToken); } await Task.WhenAny(serverTask, Task.Delay(TimeSpan.FromMinutes(1), cancellationToken)); @@ -1501,8 +1526,9 @@ namespace Tgstation.Server.Tests.Live // chat bot start and DD reattach test serverTask = server.Run(cancellationToken).AsTask(); - using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + await using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { + await jobsHubTest.WaitForReconnect(cancellationToken); var instanceClient = adminClient.Instances.CreateClient(instance); var jobs = await instanceClient.Jobs.ListActive(null, cancellationToken); @@ -1567,6 +1593,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(WatchdogStatus.Offline, dd.Status); + jobsHubTest.ExpectShutdown(); await adminClient.Administration.Restart(cancellationToken); } @@ -1585,7 +1612,6 @@ namespace Tgstation.Server.Tests.Live .Select(e => instanceClient.Jobs.GetId(e, cancellationToken)) .ToList(); - jobs = (await ValueTaskExtensions.WhenAll(getTasks)) .Where(x => x.StartedAt.Value > preStartupTime) .ToList(); @@ -1604,10 +1630,11 @@ namespace Tgstation.Server.Tests.Live serverTask = server.Run(cancellationToken).AsTask(); long expectedCompileJobId, expectedStaged; var edgeVersion = await EngineTest.GetEdgeVersion(EngineType.Byond, fileDownloader, cancellationToken); - using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + await using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { var instanceClient = adminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); + await jobsHubTest.WaitForReconnect(cancellationToken); var dd = await instanceClient.DreamDaemon.Read(cancellationToken); @@ -1643,6 +1670,7 @@ namespace Tgstation.Server.Tests.Live await wdt.WaitForJob(compileJob, 30, false, null, cancellationToken); expectedStaged = compileJob.Id.Value; + jobsHubTest.ExpectShutdown(); await adminClient.Administration.Restart(cancellationToken); } @@ -1651,10 +1679,11 @@ namespace Tgstation.Server.Tests.Live // post/entity deletion tests serverTask = server.Run(cancellationToken).AsTask(); - using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) + await using (var adminClient = await CreateAdminClient(server.Url, cancellationToken)) { var instanceClient = adminClient.Instances.CreateClient(instance); await WaitForInitialJobs(instanceClient); + await jobsHubTest.WaitForReconnect(cancellationToken); var currentDD = await instanceClient.DreamDaemon.Read(cancellationToken); Assert.AreEqual(expectedCompileJobId, currentDD.ActiveCompileJob.Id.Value); @@ -1670,6 +1699,9 @@ namespace Tgstation.Server.Tests.Live await new ChatTest(instanceClient.ChatBots, adminClient.Instances, instanceClient.Jobs, instance).RunPostTest(cancellationToken); await repoTest; + jobsHubTest.CompleteNow(); + await jobsHubTestTask; + await new InstanceManagerTest(adminClient, server.Directory).RunPostTest(instance, cancellationToken); } } @@ -1678,6 +1710,11 @@ namespace Tgstation.Server.Tests.Live Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex.ErrorCode}: {ex.Message}\n{ex.AdditionalServerData}"); throw; } + catch(OperationCanceledException ex) + { + Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ABORTED: {ex}"); + throw; + } catch (Exception ex) { Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex}"); diff --git a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj index 82c98aa9f0..388ad80adc 100644 --- a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj +++ b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj @@ -5,6 +5,10 @@ $(TgsFrameworkVersion) + + + + diff --git a/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj b/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj index 48ca05263d..89310b0d5f 100644 --- a/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj +++ b/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj @@ -14,6 +14,7 @@ +