From 4c439cb2dc67a65dfbb603e70aadeb19124ad1f4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 21 Oct 2023 22:54:54 -0400 Subject: [PATCH 01/72] Switch to using modern endpoint routing --- src/Tgstation.Server.Host/Core/Application.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 18abdcacf3..811c77d708 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -247,7 +247,6 @@ namespace Tgstation.Server.Host.Core services .AddMvc(options => { - options.EnableEndpointRouting = false; options.ReturnHttpNotAcceptable = true; options.RespectBrowserAcceptHeader = true; }) @@ -517,14 +516,24 @@ namespace Tgstation.Server.Host.Core // Do not cache a single thing beyond this point, it's all API applicationBuilder.UseDisabledClientCache(); + // Stack overflow said this needs to go here and removing it breaks things: https://stackoverflow.com/questions/73736879/invalidoperationexception-endpointroutingmiddleware-matches-endpoints-setup-by + applicationBuilder.UseRouting(); + // 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 => + { + // majority of handling is done in the controllers + endpoints.MapControllers(); + }); // 404 anything that gets this far // End of request pipeline setup From af2570ef790f666c2b384d234987e941a012bcbc Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Oct 2023 20:03:57 -0400 Subject: [PATCH 02/72] Move `TgsAuthorizeAttribute` from `Controllers` namespace to `Security` --- src/Tgstation.Server.Host/Security/IClaimsInjector.cs | 2 +- .../{Controllers => Security}/TgsAuthorizeAttribute.cs | 2 +- src/Tgstation.Server.Host/Utils/SwaggerConfiguration.cs | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) rename src/Tgstation.Server.Host/{Controllers => Security}/TgsAuthorizeAttribute.cs (98%) diff --git a/src/Tgstation.Server.Host/Security/IClaimsInjector.cs b/src/Tgstation.Server.Host/Security/IClaimsInjector.cs index 52dbebe6af..94899d81ff 100644 --- a/src/Tgstation.Server.Host/Security/IClaimsInjector.cs +++ b/src/Tgstation.Server.Host/Security/IClaimsInjector.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; namespace Tgstation.Server.Host.Security { /// - /// For injecting s that can look for. + /// For injecting s that can look for. /// interface IClaimsInjector { 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 a16cf7646d..2f6b62a848 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/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 { From 9b405784cc4555e4eed90b72f4f19c939ac23a24 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 22 Oct 2023 19:51:26 -0400 Subject: [PATCH 03/72] Fix a race condition in compat tests --- .../Tgstation.Server.Tests/Live/Instance/InstanceTest.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs index 657849d25f..69e6b00802 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/InstanceTest.cs @@ -165,16 +165,19 @@ 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, 60, false, null, cancellationToken), jrt.WaitForJob(cloneRequest.Result.ActiveJob, 60, false, null, cancellationToken), - jrt.WaitForJob(theJobWeWant, 30, false, null, cancellationToken), dmUpdateRequest.AsTask(), cloneRequest.AsTask()); + 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 From d46292bbdb9d1cbb7de087a3d65eff1d62f62e75 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:37:40 -0400 Subject: [PATCH 04/72] Nuget package updates --- build/TestCommon.props | 4 ++-- .../Tgstation.Server.Host.csproj | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) 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/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index f2844ae82b..e156db8d18 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -75,19 +75,19 @@ - + - + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive - + - + @@ -117,11 +117,11 @@ - + - + From 30f57d28dafc7a83c69fc100da7ead96797c2643 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:43:24 -0400 Subject: [PATCH 05/72] Store `TokenResponse` in `ApiHeaders` - Decode it from JWT if necessary. --- src/Tgstation.Server.Api/ApiHeaders.cs | 81 ++++++++++++++++--- .../Models/Response/TokenResponse.cs | 2 +- .../Tgstation.Server.Api.csproj | 2 + src/Tgstation.Server.Client/ApiClient.cs | 2 +- src/Tgstation.Server.Client/ServerClient.cs | 22 +---- .../ServerClientFactory.cs | 26 ++++-- .../Controllers/HomeController.cs | 4 +- .../TestApiHeaders.cs | 5 +- .../TestApiClient.cs | 24 +++++- 9 files changed, 127 insertions(+), 41 deletions(-) diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 7fe6edafc3..e9b721a303 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -9,9 +9,14 @@ using System.Text; using Microsoft.AspNetCore.Http.Headers; using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.Net.Http.Headers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Properties; using Tgstation.Server.Common.Extensions; @@ -93,9 +98,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. @@ -117,6 +122,11 @@ namespace Tgstation.Server.Api /// public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue; + /// + /// The OAuth code in use. + /// + readonly string? oAuthCode; + /// /// Checks if a given is compatible with our own. /// @@ -129,16 +139,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)); + + this.oAuthCode = oAuthCode ?? throw new ArgumentNullException(nameof(oAuthCode)); + OAuthProvider = oAuthProvider; } /// @@ -244,7 +269,36 @@ namespace Tgstation.Server.Api goto case BearerAuthenticationScheme; case BearerAuthenticationScheme: - Token = parameter; + var tokenSplits = parameter.Split('.'); + DateTimeOffset? expiresAt = null; + if (tokenSplits.Length != 3) + AddError(HeaderTypes.Authorization, "Invalid JWT!"); + else + try + { + var bytes = Convert.FromBase64String(tokenSplits[1]); + var json = Encoding.UTF8.GetString(bytes); + var jwt = JsonConvert.DeserializeObject(json); + var nbf = jwt?.Value(JwtRegisteredClaimNames.Nbf); + + if (nbf != null) + if (Int64.TryParse(nbf, out var unixTimeSeconds)) + expiresAt = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds); + else + AddError(HeaderTypes.Authorization, "'nbf' in JWT could not be parsed!"); + else + AddError(HeaderTypes.Authorization, "Missing 'nbf' in JWT payload!"); + } + catch + { + AddError(HeaderTypes.Authorization, "Invalid JWT payload!"); + } + + Token = new TokenResponse + { + Bearer = parameter, + ExpiresAt = expiresAt, + }; break; case BasicAuthenticationScheme: string badBasicAuthHeaderMessage = $"Invalid basic {HeaderNames.Authorization} header!"; @@ -292,7 +346,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 +376,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, Token!.Bearer); headers.Add(OAuthProviderHeader, OAuthProvider.ToString()); } else if (!IsTokenAuthentication) @@ -333,11 +387,18 @@ 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)); } + + /// + /// 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/Models/Response/TokenResponse.cs b/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs index cd143ede8c..cb24b941a6 100644 --- a/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs +++ b/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs @@ -15,6 +15,6 @@ namespace Tgstation.Server.Api.Models.Response /// /// When the expires. /// - public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } } } diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index 582c079b6e..dfbd15a36d 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..cb0bda67da 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -411,7 +411,7 @@ namespace Tgstation.Server.Client return true; var token = await RunRequest(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken).ConfigureAwait(false); - headers = new ApiHeaders(headers.UserAgent!, token.Bearer!); + headers = new ApiHeaders(headers.UserAgent!, token); } catch (ClientException) { diff --git a/src/Tgstation.Server.Client/ServerClient.cs b/src/Tgstation.Server.Client/ServerClient.cs index 81111d475b..207a085e28 100644 --- a/src/Tgstation.Server.Client/ServerClient.cs +++ b/src/Tgstation.Server.Client/ServerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; @@ -16,12 +16,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 +44,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); diff --git a/src/Tgstation.Server.Client/ServerClientFactory.cs b/src/Tgstation.Server.Client/ServerClientFactory.cs index 3dcb6ec67d..2d464ec0ec 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); + using var api = ApiClientFactory.CreateApiClient( + host, + new ApiHeaders( + productHeaderValue, + new TokenResponse + { + Bearer = "unused", + }), + null, + true); if (requestLoggers != null) foreach (var requestLogger in requestLoggers) @@ -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.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 8aa6d3d03b..1c832f5d81 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -261,7 +261,7 @@ namespace Tgstation.Server.Host.Controllers return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled)); externalUserId = await validator - .ValidateResponseCode(ApiHeaders.Token, cancellationToken); + .ValidateResponseCode(ApiHeaders.Token.Bearer!, cancellationToken); Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); } @@ -373,7 +373,7 @@ namespace Tgstation.Server.Host.Controllers if (usingSystemIdentity) { // expire the identity slightly after the auth token in case of lag - var identExpiry = token.ExpiresAt; + var identExpiry = token.ExpiresAt.Value; identExpiry += tokenFactory.ValidationParameters.ClockSkew; identExpiry += TimeSpan.FromSeconds(15); identityCache.CacheSystemIdentity(user, systemIdentity, identExpiry); diff --git a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index e53a4f8e1a..5261d8719a 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,7 +39,7 @@ 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 } }; diff --git a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs index d4c81db6c3..47c5a13058 100644 --- a/tests/Tgstation.Server.Client.Tests/TestApiClient.cs +++ b/tests/Tgstation.Server.Client.Tests/TestApiClient.cs @@ -41,7 +41,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 = "fake", + }), + null, + false); var result = await client.Read(Routes.Byond, default); Assert.AreEqual(sample.Version, result.Version); @@ -66,7 +76,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 = "fake" + }), + null, + false); await Assert.ThrowsExceptionAsync(() => client.Read(Routes.Byond, default).AsTask()); } From 8ead755799b7c3e208708ecb3a85e84520940cea Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:47:30 -0400 Subject: [PATCH 06/72] Clean up line width --- src/Tgstation.Server.Client/IApiClientFactory.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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); } } From c3168c4e00ba075bab36693e441a7eb34a8fdce2 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:47:47 -0400 Subject: [PATCH 07/72] Remove unnecessary braces --- src/Tgstation.Server.Host/Server.cs | 2 -- 1 file changed, 2 deletions(-) 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 { From 4f3f12f05dbaaf7378ab737d2262a9cc4d92c28c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:48:11 -0400 Subject: [PATCH 08/72] Silence a VS message about a `new()` expression --- .../Components/Chat/Providers/DiscordProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index 1419bd70c4..de80447953 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -380,7 +380,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, From 5a5997492bb9839b5d3dde7de96b91beb0e18a0f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:48:57 -0400 Subject: [PATCH 09/72] Fix routing in `ControlPanelController` - This was generating an unnecessary 301 when getting `/app` instead of `/app`. --- src/Tgstation.Server.Host/Controllers/ControlPanelController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 9325e545fd9ab4b93a107792375b43b5f9fdc83a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:50:49 -0400 Subject: [PATCH 10/72] Simplify some `Substring` calls --- tests/Tgstation.Server.Tests/Live/TestLiveServer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 0d8fec8488..b7a430b810 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1106,9 +1106,9 @@ namespace Tgstation.Server.Tests.Live var dependenciesSh = Encoding.UTF8.GetString(await depsBytesTask); var lines = dependenciesSh.Split("\n", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); const string MajorPrefix = "export BYOND_MAJOR="; - var major = Int32.Parse(lines.First(x => x.StartsWith(MajorPrefix)).Substring(MajorPrefix.Length)); + var major = Int32.Parse(lines.First(x => x.StartsWith(MajorPrefix))[MajorPrefix.Length..]); const string MinorPrefix = "export BYOND_MINOR="; - var minor = Int32.Parse(lines.First(x => x.StartsWith(MinorPrefix)).Substring(MinorPrefix.Length)); + var minor = Int32.Parse(lines.First(x => x.StartsWith(MinorPrefix))[MinorPrefix.Length..]); var byondJob = await instanceClient.Byond.SetActiveVersion(new ByondVersionRequest { From 10728657df94d987abc44bad42fc174bce63f806 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:55:06 -0400 Subject: [PATCH 11/72] Move custom controller results to new namespace --- .../Controllers/AdministrationController.cs | 1 + src/Tgstation.Server.Host/Controllers/ApiController.cs | 1 + src/Tgstation.Server.Host/Controllers/ByondController.cs | 1 + src/Tgstation.Server.Host/Controllers/ChatController.cs | 1 + .../Controllers/ConfigurationController.cs | 1 + src/Tgstation.Server.Host/Controllers/DreamMakerController.cs | 1 + src/Tgstation.Server.Host/Controllers/InstanceController.cs | 1 + .../Controllers/InstancePermissionSetController.cs | 1 + src/Tgstation.Server.Host/Controllers/JobController.cs | 1 + .../Controllers/{ => Results}/LimitedStreamResult.cs | 2 +- .../Controllers/{ => Results}/LimitedStreamResultExecutor.cs | 2 +- .../Controllers/{ => Results}/PaginatableResult.cs | 2 +- src/Tgstation.Server.Host/Controllers/TransferController.cs | 1 + src/Tgstation.Server.Host/Controllers/UserController.cs | 1 + src/Tgstation.Server.Host/Controllers/UserGroupController.cs | 1 + src/Tgstation.Server.Host/Core/Application.cs | 1 + .../Extensions/FileTransferStreamHandlerExtensions.cs | 2 +- tests/Tgstation.Server.Host.Tests/Swarm/SwarmRpcMapper.cs | 1 + 18 files changed, 18 insertions(+), 4 deletions(-) rename src/Tgstation.Server.Host/Controllers/{ => Results}/LimitedStreamResult.cs (96%) rename src/Tgstation.Server.Host/Controllers/{ => Results}/LimitedStreamResultExecutor.cs (97%) rename src/Tgstation.Server.Host/Controllers/{ => Results}/PaginatableResult.cs (96%) diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index b742a2ae18..567d51bd07 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; diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index c0f37fbf82..98fd2b401f 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -23,6 +23,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; diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 156ff784e9..2c0281b99b 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.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; diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index c1365b1f84..b88ab73654 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -19,6 +19,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.Models; diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index b71703af41..adebfe0103 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -12,6 +12,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.IO; using Tgstation.Server.Host.Models; diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 3c4431a3e1..acb4d53dac 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; diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 0e2ebf234e..6a30e25399 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; diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index ca8fdae67b..2d9059b204 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.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.Models; diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 84f4cc5084..6e0817b553 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -11,6 +11,7 @@ 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; 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/TransferController.cs b/src/Tgstation.Server.Host/Controllers/TransferController.cs index be12cfda91..489b3525cb 100644 --- a/src/Tgstation.Server.Host/Controllers/TransferController.cs +++ b/src/Tgstation.Server.Host/Controllers/TransferController.cs @@ -9,6 +9,7 @@ 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; diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 04559bde3c..50c071ee99 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -15,6 +15,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.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index 14269e3254..a23be84706 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -14,6 +14,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.Database; using Tgstation.Server.Host.Extensions; using Tgstation.Server.Host.Models; diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 811c77d708..1e4a745835 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -40,6 +40,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; 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/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 From 548d8ccf092d36b2a04adff51ec919036b396417 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:57:00 -0400 Subject: [PATCH 12/72] Minor code cleanups in `TokenFactory` --- src/Tgstation.Server.Host/Security/TokenFactory.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index 81a96876f6..9ceac820ca 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -112,7 +112,7 @@ 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 @@ -125,9 +125,9 @@ namespace Tgstation.Server.Host.Security : 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)), + new (JwtRegisteredClaimNames.Sub, user.Id.Value.ToString(CultureInfo.InvariantCulture)), + new (JwtRegisteredClaimNames.Exp, expiry.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)), + new (JwtRegisteredClaimNames.Nbf, nowUnix.ToString(CultureInfo.InvariantCulture)), issuerClaim, audienceClaim, }; From 49a351ecc6b259ae3343a7a154357f5372b8d74e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 14:59:44 -0400 Subject: [PATCH 13/72] Move client cache disabling to new `ApiControllerBase` It's probably not in a good middleware spot anymore with endpoint routing. Also disable CA1501 (Excessive inheritance) because our use cases appear to be consistently valid. --- build/analyzers.ruleset | 8 +-- .../Models/Request/UserCreateRequest.cs | 1 - .../Controllers/ApiController.cs | 50 ++++++------------- .../Controllers/ApiControllerBase.cs | 48 ++++++++++++++++++ .../Controllers/BridgeController.cs | 4 +- .../Controllers/SwarmController.cs | 22 +++----- src/Tgstation.Server.Host/Core/Application.cs | 3 -- .../ApplicationBuilderExtensions.cs | 16 ------ 8 files changed, 76 insertions(+), 76 deletions(-) create mode 100644 src/Tgstation.Server.Host/Controllers/ApiControllerBase.cs diff --git a/build/analyzers.ruleset b/build/analyzers.ruleset index 365db1e57f..0f86cbd400 100644 --- a/build/analyzers.ruleset +++ b/build/analyzers.ruleset @@ -1,4 +1,4 @@ - + @@ -88,7 +88,7 @@ - + @@ -971,7 +971,7 @@ - + @@ -1043,4 +1043,4 @@ - \ No newline at end of file + 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.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 98fd2b401f..3feebe9c26 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; @@ -35,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. @@ -102,18 +98,14 @@ namespace Tgstation.Server.Host.Controllers /// #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); + ArgumentNullException.ThrowIfNull(executeAction); // 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; - } + return Unauthorized(); // valid token, expired password // validate the headers try @@ -121,29 +113,18 @@ namespace Tgstation.Server.Host.Controllers ApiHeaders = new ApiHeaders(Request.GetTypedHeaders()); if (!ApiHeaders.Compatible()) - { - await this.StatusCode( + return this.StatusCode( HttpStatusCode.UpgradeRequired, - new ErrorMessageResponse(ErrorCode.ApiMismatch)) - .ExecuteResultAsync(context); - return; - } + new ErrorMessageResponse(ErrorCode.ApiMismatch)); - var errorCase = await ValidateRequest(context.HttpContext.RequestAborted); + var errorCase = await ValidateRequest(cancellationToken); if (errorCase != null) - { - await errorCase.ExecuteResultAsync(context); - return; - } + return errorCase; } catch (HeadersException) { if (requireHeaders) - { - await HeadersIssue(false) - .ExecuteResultAsync(context); - return; - } + return HeadersIssue(false); } if (ModelState?.IsValid == false) @@ -159,15 +140,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(); } @@ -202,8 +179,11 @@ 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 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 1f53b43ec2..6afb32bc1d 100644 --- a/src/Tgstation.Server.Host/Controllers/BridgeController.cs +++ b/src/Tgstation.Server.Host/Controllers/BridgeController.cs @@ -20,10 +20,8 @@ namespace Tgstation.Server.Host.Controllers /// for recieving DMAPI requests from DreamDaemon. /// [Route("/Bridge")] - [Produces(MediaTypeNames.Application.Json)] - [ApiController] [ApiExplorerSettings(IgnoreApi = true)] - public class BridgeController : Controller + public class BridgeController : ApiControllerBase { /// /// If the content of bridge requests and responses should be logged. diff --git a/src/Tgstation.Server.Host/Controllers/SwarmController.cs b/src/Tgstation.Server.Host/Controllers/SwarmController.cs index 8b941d425c..fb81784b12 100644 --- a/src/Tgstation.Server.Host/Controllers/SwarmController.cs +++ b/src/Tgstation.Server.Host/Controllers/SwarmController.cs @@ -6,7 +6,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -25,10 +24,8 @@ 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 + public sealed class SwarmController : ApiControllerBase { /// /// Get the current registration from the . @@ -211,7 +208,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 +216,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 +224,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 +232,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 +246,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/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 1e4a745835..938ce47fed 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -514,9 +514,6 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Web control panel disabled!"); #endif - // Do not cache a single thing beyond this point, it's all API - applicationBuilder.UseDisabledClientCache(); - // Stack overflow said this needs to go here and removing it breaks things: https://stackoverflow.com/questions/73736879/invalidoperationexception-endpointroutingmiddleware-matches-endpoints-setup-by applicationBuilder.UseRouting(); diff --git a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs index 42bb3ad8fb..ae8eb65ae6 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. /// From 5b615ea04b489d1de481e2d5ad9c72327ea779b9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 15:07:33 -0400 Subject: [PATCH 14/72] Upgrade "webpanel not built" message to `Debug` --- src/Tgstation.Server.Host/Core/Application.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 938ce47fed..329b226270 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -509,7 +509,7 @@ 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 From 78669f486c1936a5360ae858853e55ba075df20d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 15:08:07 -0400 Subject: [PATCH 15/72] Fix issue with CORS not working properly --- src/Tgstation.Server.Host/Core/Application.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 329b226270..459272d77e 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -472,6 +472,9 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Swagger API generation enabled"); } + // Enable endpoint routing + applicationBuilder.UseRouting(); + // Set up CORS based on configuration if necessary Action corsBuilder = null; if (controlPanelConfiguration.AllowAnyOrigin) @@ -514,9 +517,6 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Web control panel disabled!"); #endif - // Stack overflow said this needs to go here and removing it breaks things: https://stackoverflow.com/questions/73736879/invalidoperationexception-endpointroutingmiddleware-matches-endpoints-setup-by - applicationBuilder.UseRouting(); - // authenticate JWT tokens using our security pipeline if present, returns 401 if bad applicationBuilder.UseAuthentication(); From a7cb72ca8472495d85fa45d3d3e8a4aee4cb011b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 15:11:08 -0400 Subject: [PATCH 16/72] Fix BOM in `ServerClient.cs` --- src/Tgstation.Server.Client/ServerClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Client/ServerClient.cs b/src/Tgstation.Server.Client/ServerClient.cs index 207a085e28..5826b76bdc 100644 --- a/src/Tgstation.Server.Client/ServerClient.cs +++ b/src/Tgstation.Server.Client/ServerClient.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; From 4b39bde25d69a4bffa4a64cad52b13359d43650b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 15:12:27 -0400 Subject: [PATCH 17/72] Fix `HardFailLogger`/provider logging non-errors --- tests/Tgstation.Server.Tests/Live/HardFailLogger.cs | 1 - tests/Tgstation.Server.Tests/Live/HardFailLoggerProvider.cs | 6 +++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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() { } From 590f5c6a5510a511cc30291436e5dadeba4cfdac Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 15:26:34 -0400 Subject: [PATCH 18/72] Fix `ApiHeaders` improperly storing `OAuthCode` - Store in its own property instead of `Token` --- src/Tgstation.Server.Api/ApiHeaders.cs | 19 ++++++++++--------- .../Controllers/HomeController.cs | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index e9b721a303..5670abb8a3 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -112,6 +112,11 @@ namespace Tgstation.Server.Api /// public string? Password { get; } + /// + /// The OAuth code in use. + /// + public string? OAuthCode { get; } + /// /// The the is for, if any. /// @@ -122,11 +127,6 @@ namespace Tgstation.Server.Api /// public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue; - /// - /// The OAuth code in use. - /// - readonly string? oAuthCode; - /// /// Checks if a given is compatible with our own. /// @@ -154,7 +154,7 @@ namespace Tgstation.Server.Api /// Initializes a new instance of the class. Used for token authentication. /// /// The value of . - /// The value of . + /// The value of . /// The value of . public ApiHeaders(ProductHeaderValue userAgent, string oAuthCode, OAuthProvider oAuthProvider) : this(userAgent, null, null, null) @@ -162,7 +162,7 @@ namespace Tgstation.Server.Api if (userAgent == null) throw new ArgumentNullException(nameof(userAgent)); - this.oAuthCode = oAuthCode ?? throw new ArgumentNullException(nameof(oAuthCode)); + OAuthCode = oAuthCode ?? throw new ArgumentNullException(nameof(oAuthCode)); OAuthProvider = oAuthProvider; } @@ -267,7 +267,8 @@ namespace Tgstation.Server.Api else AddError(HeaderTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!"); - goto case BearerAuthenticationScheme; + OAuthCode = parameter; + break; case BearerAuthenticationScheme: var tokenSplits = parameter.Split('.'); DateTimeOffset? expiresAt = null; @@ -379,7 +380,7 @@ namespace Tgstation.Server.Api headers.Add(ApiVersionHeader, CreateApiVersionHeader()); if (OAuthProvider.HasValue) { - headers.Authorization = new AuthenticationHeaderValue(OAuthAuthenticationScheme, Token!.Bearer); + headers.Authorization = new AuthenticationHeaderValue(OAuthAuthenticationScheme, OAuthCode!); headers.Add(OAuthProviderHeader, OAuthProvider.ToString()); } else if (!IsTokenAuthentication) diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 1c832f5d81..af1d4b90dd 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -261,7 +261,7 @@ namespace Tgstation.Server.Host.Controllers return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled)); externalUserId = await validator - .ValidateResponseCode(ApiHeaders.Token.Bearer!, cancellationToken); + .ValidateResponseCode(ApiHeaders.OAuthCode!, cancellationToken); Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId); } From fd44b61a24541f6c7d7b392550a4d3c4b5fb29ba Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 15:15:26 -0400 Subject: [PATCH 19/72] Authentication pipeline rewrite If you're looking for the future CVE it probably came from here. --- .../Controllers/AdministrationController.cs | 12 ++- .../Controllers/ApiController.cs | 45 ++++---- .../Controllers/BridgeController.cs | 4 +- .../Controllers/ByondController.cs | 14 ++- .../Controllers/ChatController.cs | 15 +-- .../ComponentInterfacingController.cs | 11 +- .../Controllers/ConfigurationController.cs | 14 ++- .../Controllers/DreamDaemonController.cs | 13 ++- .../Controllers/DreamMakerController.cs | 13 ++- .../Controllers/HomeController.cs | 14 ++- .../Controllers/InstanceController.cs | 16 +-- .../InstancePermissionSetController.cs | 14 ++- .../Controllers/InstanceRequiredController.cs | 12 ++- .../Controllers/JobController.cs | 16 +-- .../Controllers/RepositoryController.cs | 14 ++- .../Controllers/SwarmController.cs | 2 + .../Controllers/TransferController.cs | 12 ++- .../Controllers/UserController.cs | 14 ++- .../Controllers/UserGroupController.cs | 20 ++-- src/Tgstation.Server.Host/Core/Application.cs | 101 +++++++++++------- .../Security/AuthenticationContext.cs | 32 ++++-- ...uthenticationContextAuthorizationFilter.cs | 53 +++++++++ ...thenticationContextClaimsTransformation.cs | 97 +++++++++++++++++ .../Security/AuthenticationContextFactory.cs | 63 ++++++++--- .../Security/ClaimsInjector.cs | 95 ---------------- .../Security/IAuthenticationContext.cs | 11 +- .../Security/IAuthenticationContextFactory.cs | 19 ++-- .../Security/IClaimsInjector.cs | 21 ---- .../Utils/ApiHeadersProvider.cs | 39 +++++++ .../Utils/IApiHeadersProvider.cs | 15 +++ .../Security/TestAuthenticationContext.cs | 23 ++-- 31 files changed, 528 insertions(+), 316 deletions(-) create mode 100644 src/Tgstation.Server.Host/Security/AuthenticationContextAuthorizationFilter.cs create mode 100644 src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs delete mode 100644 src/Tgstation.Server.Host/Security/ClaimsInjector.cs delete mode 100644 src/Tgstation.Server.Host/Security/IClaimsInjector.cs create mode 100644 src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs create mode 100644 src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 567d51bd07..c1da69a69d 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -26,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 @@ -85,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 . @@ -95,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, @@ -106,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 3feebe9c26..9fd4b6154a 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -48,7 +48,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the operation. /// - protected ApiHeaders ApiHeaders { get; private set; } + protected ApiHeaders ApiHeaders { get; } /// /// The for the operation. @@ -79,20 +79,24 @@ 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 containing 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)); + ArgumentNullException.ThrowIfNull(apiHeadersProvider); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - AuthenticationContext = authenticationContextFactory.CurrentAuthenticationContext; + Instance = AuthenticationContext?.InstancePermissionSet?.Instance; + ApiHeaders = apiHeadersProvider.ApiHeaders; this.requireHeaders = requireHeaders; } @@ -102,30 +106,20 @@ namespace Tgstation.Server.Host.Controllers { ArgumentNullException.ThrowIfNull(executeAction); - // ALL valid token and login requests that match a route go through this function - // 404 is returned before - if (AuthenticationContext != null && AuthenticationContext.User == null) - return Unauthorized(); // valid token, expired password - // validate the headers - try - { - ApiHeaders = new ApiHeaders(Request.GetTypedHeaders()); - - if (!ApiHeaders.Compatible()) - return this.StatusCode( - HttpStatusCode.UpgradeRequired, - new ErrorMessageResponse(ErrorCode.ApiMismatch)); - - var errorCase = await ValidateRequest(cancellationToken); - if (errorCase != null) - return errorCase; - } - catch (HeadersException) + if (ApiHeaders == null) { if (requireHeaders) return HeadersIssue(false); } + else if (!ApiHeaders.Compatible()) + return this.StatusCode( + HttpStatusCode.UpgradeRequired, + new ErrorMessageResponse(ErrorCode.ApiMismatch)); + + var errorCase = await ValidateRequest(cancellationToken); + if (errorCase != null) + return errorCase; if (ModelState?.IsValid == false) { @@ -152,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}")) @@ -252,6 +246,7 @@ namespace Tgstation.Server.Host.Controllers HeadersException headersException; try { + // TODO: Move this somewhere saner? _ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth); throw new InvalidOperationException("Expected a header parse exception!"); } diff --git a/src/Tgstation.Server.Host/Controllers/BridgeController.cs b/src/Tgstation.Server.Host/Controllers/BridgeController.cs index 6afb32bc1d..4ae2e0497c 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; @@ -21,7 +22,7 @@ namespace Tgstation.Server.Host.Controllers /// [Route("/Bridge")] [ApiExplorerSettings(IgnoreApi = true)] - public class BridgeController : ApiControllerBase + public sealed class BridgeController : ApiControllerBase { /// /// If the content of bridge requests and responses should be logged. @@ -74,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/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 2c0281b99b..41cccc1584 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -20,6 +20,7 @@ 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 +51,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 ByondController( 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)); diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index b88ab73654..7f9682c43a 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -24,7 +24,7 @@ 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 @@ -40,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 adebfe0103..ea26922fd8 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -17,6 +17,7 @@ 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 { @@ -35,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)); } diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index f4613f81d0..5bceb479d5 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)); diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index acb4d53dac..c2e4874ea4 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -44,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)); diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index af1d4b90dd..dda8e79336 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net; using System.Threading; @@ -27,6 +27,7 @@ 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 +96,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 +109,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 +124,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) { diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 6a30e25399..c5e472b7ad 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -82,7 +82,7 @@ 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 . @@ -91,9 +91,10 @@ namespace Tgstation.Server.Host.Controllers /// 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, @@ -101,12 +102,15 @@ namespace Tgstation.Server.Host.Controllers IPortAllocator portAllocator, IPlatformIdentifier platformIdentifier, 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)); diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index 2d9059b204..5c1a91e6ba 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -18,6 +18,7 @@ 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; @@ -33,19 +34,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 InstancePermissionSetController( 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/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 6e0817b553..9b468546bc 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -17,6 +17,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 { @@ -35,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)); } diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index 0b02d7d0c1..d701142bfd 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)); diff --git a/src/Tgstation.Server.Host/Controllers/SwarmController.cs b/src/Tgstation.Server.Host/Controllers/SwarmController.cs index fb81784b12..15b3bae421 100644 --- a/src/Tgstation.Server.Host/Controllers/SwarmController.cs +++ b/src/Tgstation.Server.Host/Controllers/SwarmController.cs @@ -5,6 +5,7 @@ using System.Net.Mime; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -25,6 +26,7 @@ namespace Tgstation.Server.Host.Controllers /// [Route(SwarmConstants.ControllerRoute)] [ApiExplorerSettings(IgnoreApi = true)] + [AllowAnonymous] // We have custom private key auth public sealed class SwarmController : ApiControllerBase { /// diff --git a/src/Tgstation.Server.Host/Controllers/TransferController.cs b/src/Tgstation.Server.Host/Controllers/TransferController.cs index 489b3525cb..8a0a2ebcd3 100644 --- a/src/Tgstation.Server.Host/Controllers/TransferController.cs +++ b/src/Tgstation.Server.Host/Controllers/TransferController.cs @@ -14,6 +14,7 @@ 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 { @@ -33,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 50c071ee99..88ce9d1abb 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -20,6 +20,7 @@ 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 { @@ -48,21 +49,24 @@ 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 for the . /// The containing the value of . + /// The for the . public UserController( IDatabaseContext databaseContext, - IAuthenticationContextFactory authenticationContextFactory, + IAuthenticationContext authenticationContext, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger logger, - IOptions generalConfigurationOptions) + IOptions generalConfigurationOptions, + IApiHeadersProvider apiHeaders) : base( databaseContext, - authenticationContextFactory, + authenticationContext, + apiHeaders, logger, true) { diff --git a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs index a23be84706..589c1ecb46 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -19,6 +19,7 @@ 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; @@ -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 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)); } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 459272d77e..7253e90d68 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -3,16 +3,19 @@ using System.Collections.Generic; using System.Globalization; using System.IdentityModel.Tokens.Jwt; 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.Identity; +using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -115,7 +118,7 @@ namespace Tgstation.Server.Host.Core } /// - /// Configure the 's services. + /// Configure the 's . /// /// The to configure. /// The needed for configuration. @@ -217,34 +220,24 @@ 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.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), - }; - }); - - // WARNING: STATIC CODE - // fucking prevents converting 'sub' to M$ bs - // can't be done in the above lambda, that's too late - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + // 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 => { @@ -254,14 +247,7 @@ namespace Tgstation.Server.Host.Core .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); }); if (postSetupServices.GeneralConfiguration.HostApiDocumentation) @@ -322,9 +308,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(); @@ -546,5 +530,46 @@ 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()); + services.AddScoped(provider => + { + return provider.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.Events = new JwtBearerEvents + { + OnTokenValidated = tokenValidatedContext => + { + var acf = tokenValidatedContext.HttpContext.RequestServices.GetRequiredService(); + acf.SetTokenNbf(tokenValidatedContext.SecurityToken.ValidFrom); + return Task.CompletedTask; + }, + }; + }); + + // WARNING: STATIC CODE + // fucking prevents converting 'sub' to M$ bs + // can't be done in the above lambda, that's too late + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + } } } diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 105c7f9837..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,10 +74,15 @@ 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!"); + return (ulong)right; } } 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..6cd5f30305 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -0,0 +1,97 @@ +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 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 required claim!"); + + long userId; + try + { + userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture); + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to parse user ID!", e); + } + + var authenticationContext = await authenticationContextFactory.CreateAuthenticationContext( + userId, + apiHeaders?.InstanceId, + 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..3b5035e69c 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,21 @@ namespace Tgstation.Server.Host.Security /// readonly SwarmConfiguration swarmConfiguration; + /// + /// Backing field for . + /// + readonly AuthenticationContext currentAuthenticationContext; + + /// + /// The the request's token must be valid after. + /// + DateTimeOffset? validAfter; + + /// + /// 1 if was initialized, 0 otherwise. + /// + int initialized; + /// /// Initializes a new instance of the class. /// @@ -56,17 +73,34 @@ 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(); + + /// + /// Populate with a given . + /// + /// The an issued token is not valid before. + public void SetTokenNbf(DateTimeOffset tokenNbf) + { + if (validAfter.HasValue) + throw new InvalidOperationException("SetTokenNbf called multiple times!"); + + validAfter = tokenNbf; + } /// - public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset validAfter, CancellationToken cancellationToken) + public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken) { - if (CurrentAuthenticationContext != null) + if (Interlocked.Exchange(ref initialized, 1) != 0) throw new InvalidOperationException("Authentication context has already been loaded"); + if (!validAfter.HasValue) + throw new InvalidOperationException("SetTokenNbf has not been called!"); + var user = await databaseContext .Users .AsQueryable() @@ -79,9 +113,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 +122,10 @@ namespace Tgstation.Server.Host.Security systemIdentity = identityCache.LoadCachedIdentity(user); else { - if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter) + if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter.Value) { - 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 +144,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/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..0233700585 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace Tgstation.Server.Host.Security @@ -10,18 +9,12 @@ 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 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, 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 94899d81ff..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/Utils/ApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs new file mode 100644 index 0000000000..c59d2d67d0 --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs @@ -0,0 +1,39 @@ +using System; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Tgstation.Server.Api; + +namespace Tgstation.Server.Host.Utils +{ + /// + sealed class ApiHeadersProvider : IApiHeadersProvider + { + /// + public ApiHeaders ApiHeaders { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The for accessing the . + /// The to write to. + public ApiHeadersProvider(IHttpContextAccessor httpContextAccessor, ILogger logger) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + + if (httpContextAccessor.HttpContext == null) + throw new InvalidOperationException("httpContextAccessor has no HttpContext!"); + + var request = httpContextAccessor.HttpContext.Request; + try + { + ApiHeaders = new ApiHeaders(request.GetTypedHeaders()); + } + catch (HeadersException ex) + { + // we are not responsible for handling header validation issues + logger.LogTrace(ex, "Failed to validated API request headers!"); + } + } + } +} diff --git a/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs new file mode 100644 index 0000000000..31747862be --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs @@ -0,0 +1,15 @@ +using Tgstation.Server.Api; + +namespace Tgstation.Server.Host.Utils +{ + /// + /// Provides . + /// + public interface IApiHeadersProvider + { + /// + /// The created , if any. + /// + ApiHeaders ApiHeaders { get; } + } +} diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs index 090f10f1ec..9145401b34 100644 --- a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; @@ -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.ByondRights = ByondRights.InstallOfficialOrChangeActiveVersion | ByondRights.ReadActive; From 6dbf357b8a0e52f4545feadf9dbcfb1048d2e671 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:23:29 -0400 Subject: [PATCH 20/72] Silence VS message about a `new()` expression --- .../Components/Chat/Providers/IrcProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index c4449e00ea..533f5010a0 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, From 1d0371f84a98c3f75dd1b5961af974d094cadd6d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:23:52 -0400 Subject: [PATCH 21/72] Make `TestServerService` more analyzer friendly --- .../TestServerService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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); } } } From ed618de29ec33f5578a42d9e613eda2491ed2652 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:39:56 -0400 Subject: [PATCH 22/72] ODR use of "Bearer" string --- src/Tgstation.Server.Host/Controllers/HomeController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index dda8e79336..76598ef4f1 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net; using System.Threading; @@ -227,7 +227,7 @@ namespace Tgstation.Server.Host.Controllers { if (ApiHeaders == null) { - Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues("basic realm=\"Create TGS bearer token\"")); + Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\"")); return HeadersIssue(false); } From 23a4f5758f4a0cbf2c7f1f3745ff5e7999e5fb24 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:40:47 -0400 Subject: [PATCH 23/72] Include `InstanceId` in `JobResponse` --- src/Tgstation.Server.Api/Models/Response/JobResponse.cs | 5 +++++ .../Components/Deployment/DmbFactory.cs | 2 ++ .../Controllers/DreamMakerController.cs | 2 ++ src/Tgstation.Server.Host/Controllers/InstanceController.cs | 6 ++++-- src/Tgstation.Server.Host/Controllers/JobController.cs | 6 +++++- src/Tgstation.Server.Host/Models/Job.cs | 1 + 6 files changed, 19 insertions(+), 3 deletions(-) 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.Host/Components/Deployment/DmbFactory.cs b/src/Tgstation.Server.Host/Components/Deployment/DmbFactory.cs index 31bef5dfdb..fdb223da82 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) diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index c2e4874ea4..105a64d8b9 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -261,6 +261,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/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index c5e472b7ad..d4ae712281 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -627,7 +627,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); diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 9b468546bc..521a560a0b 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -78,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, @@ -105,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, @@ -132,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) @@ -167,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/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index 1a8fca57c0..a05b83b811 100644 --- a/src/Tgstation.Server.Host/Models/Job.cs +++ b/src/Tgstation.Server.Host/Models/Job.cs @@ -30,6 +30,7 @@ namespace Tgstation.Server.Host.Models public JobResponse ToApi() => new () { Id = Id, + InstanceId = Instance.Id.Value, StartedAt = StartedAt, StoppedAt = StoppedAt, Cancelled = Cancelled, From b86bd51e1fa6cb02a1bc18f08b91eab0dcd49f7b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:41:32 -0400 Subject: [PATCH 24/72] Fix some logging formatter messages --- src/Tgstation.Server.Host/Controllers/UserController.cs | 6 +++--- .../Controllers/UserGroupController.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 88ce9d1abb..38c7504302 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -285,7 +285,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; } @@ -313,7 +313,7 @@ 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); // 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 589c1ecb46..3f2c5ea3a2 100644 --- a/src/Tgstation.Server.Host/Controllers/UserGroupController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserGroupController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -98,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)); } From 808f96b9681cd42589decf9cfa9690680aefaf25 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:43:50 -0400 Subject: [PATCH 25/72] Clean up some using ordering --- .../Components/TestDreamDaemonClient.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 From ad460d9ae46bdacd4b523975af011d25ae765412 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 16:45:48 -0400 Subject: [PATCH 26/72] Silence VS messages about `new()` expressions --- src/Tgstation.Server.Host/Models/CompileJob.cs | 2 +- src/Tgstation.Server.Host/Models/UserGroup.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 9ddde1344c..5ee95f96ef 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -82,7 +82,7 @@ namespace Tgstation.Server.Host.Models } /// - public CompileJobResponse ToApi() => new CompileJobResponse + public CompileJobResponse ToApi() => new () { DirectoryName = DirectoryName, DmeName = DmeName, 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, From 7bb565d0da0539e52d1b790af449bb71f1077295 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 18:10:19 -0400 Subject: [PATCH 27/72] Better message formatting for `HeadersException` --- src/Tgstation.Server.Api/ApiHeaders.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 5670abb8a3..c912e58265 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -197,11 +197,15 @@ namespace Tgstation.Server.Api var badHeaders = HeaderTypes.None; var errorBuilder = new StringBuilder(); - + var multipleErrors = false; void AddError(HeaderTypes headerType, string message) { if (badHeaders != HeaderTypes.None) + { + multipleErrors = true; errorBuilder.AppendLine(); + } + badHeaders |= headerType; errorBuilder.Append(message); } @@ -334,7 +338,12 @@ namespace Tgstation.Server.Api } if (badHeaders != HeaderTypes.None) + { + if (multipleErrors) + errorBuilder.Insert(0, $"Multiple header validation errors occurred:{Environment.NewLine}"); + throw new HeadersException(badHeaders, errorBuilder.ToString()); + } ApiVersion = apiVersion!.Semver(); } From 3901512693ed575943a24ff3add10da9cfc4dec3 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 18:28:56 -0400 Subject: [PATCH 28/72] The `del world` issue is caused by `sleep_offline` Just do an infinite loop and ensure `sleep_offline` is off. It can't keep getting away with this. Bump DMAPI version --- build/Version.props | 2 +- src/DMAPI/tgs.dm | 2 +- src/DMAPI/tgs/core/datum.dm | 8 ++++++-- tests/DMAPI/BasicOperation/Config.dm | 9 --------- tests/DMAPI/LongRunning/Config.dm | 10 ---------- tests/DMAPI/test_prelude.dm | 11 +++++++++++ 6 files changed, 19 insertions(+), 23 deletions(-) diff --git a/build/Version.props b/build/Version.props index 5acebbddfb..ecaea3d8d9 100644 --- a/build/Version.props +++ b/build/Version.props @@ -9,7 +9,7 @@ 7.0.0 11.1.2 13.0.0 - 6.6.1 + 6.6.2 5.6.2 1.4.0 1.2.1 diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index d468d60441..0cc106ec9c 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -1,6 +1,6 @@ // tgstation-server DMAPI -#define TGS_DMAPI_VERSION "6.6.1" +#define TGS_DMAPI_VERSION "6.6.2" // All functions and datums outside this document are subject to change with any version and should not be relied on. diff --git a/src/DMAPI/tgs/core/datum.dm b/src/DMAPI/tgs/core/datum.dm index de420a2a32..935263cc95 100644 --- a/src/DMAPI/tgs/core/datum.dm +++ b/src/DMAPI/tgs/core/datum.dm @@ -12,8 +12,12 @@ 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]") + del(world) + world.sleep_offline = FALSE // https://www.byond.com/forum/post/2894866 + 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/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" From eaebe733bd29478c64920ef41ad3afe4cff406bb Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 19:13:51 -0400 Subject: [PATCH 29/72] Improve `HeadersException` handling Avoid recreating ApiHeaders in request pipeline where possible --- src/Tgstation.Server.Api/ApiHeaders.cs | 44 ++++++------- src/Tgstation.Server.Api/HeaderErrorTypes.cs | 46 +++++++++++++ src/Tgstation.Server.Api/HeaderTypes.cs | 41 ------------ src/Tgstation.Server.Api/HeadersException.cs | 10 +-- .../Controllers/ApiController.cs | 31 ++++----- .../Controllers/HomeController.cs | 8 +-- .../Utils/ApiHeadersProvider.cs | 64 ++++++++++++++++--- .../Utils/IApiHeadersProvider.cs | 13 ++++ .../TestApiHeaders.cs | 2 +- 9 files changed, 160 insertions(+), 99 deletions(-) create mode 100644 src/Tgstation.Server.Api/HeaderErrorTypes.cs delete mode 100644 src/Tgstation.Server.Api/HeaderTypes.cs diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index c912e58265..b23d890abe 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -190,17 +190,17 @@ namespace Tgstation.Server.Api /// If a missing should be ignored. /// Thrown if the constitue invalid . #pragma warning disable CA1502 // TODO: Decomplexify - public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false) + public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth) { if (requestHeaders == null) throw new ArgumentNullException(nameof(requestHeaders)); - var badHeaders = HeaderTypes.None; + var badHeaders = HeaderErrorTypes.None; var errorBuilder = new StringBuilder(); var multipleErrors = false; - void AddError(HeaderTypes headerType, string message) + void AddError(HeaderErrorTypes headerType, string message) { - if (badHeaders != HeaderTypes.None) + if (badHeaders != HeaderErrorTypes.None) { multipleErrors = true; errorBuilder.AppendLine(); @@ -212,28 +212,28 @@ namespace Tgstation.Server.Api 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}!"); + AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime}!"); 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 { @@ -241,13 +241,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)) @@ -266,10 +266,10 @@ 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!"); OAuthCode = parameter; break; @@ -277,7 +277,7 @@ namespace Tgstation.Server.Api var tokenSplits = parameter.Split('.'); DateTimeOffset? expiresAt = null; if (tokenSplits.Length != 3) - AddError(HeaderTypes.Authorization, "Invalid JWT!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid JWT!"); else try { @@ -290,13 +290,13 @@ namespace Tgstation.Server.Api if (Int64.TryParse(nbf, out var unixTimeSeconds)) expiresAt = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds); else - AddError(HeaderTypes.Authorization, "'nbf' in JWT could not be parsed!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "'nbf' in JWT could not be parsed!"); else - AddError(HeaderTypes.Authorization, "Missing 'nbf' in JWT payload!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing 'nbf' in JWT payload!"); } catch { - AddError(HeaderTypes.Authorization, "Invalid JWT payload!"); + AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid JWT payload!"); } Token = new TokenResponse @@ -315,14 +315,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; } @@ -330,14 +330,14 @@ 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}"); 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.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 9fd4b6154a..d0b7213427 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -48,7 +48,12 @@ namespace Tgstation.Server.Host.Controllers /// /// The for the operation. /// - protected ApiHeaders ApiHeaders { get; } + protected ApiHeaders ApiHeaders => ApiHeadersProvider.ApiHeaders; + + /// + /// The containing value of . + /// + protected IApiHeadersProvider ApiHeadersProvider { get; } /// /// The for the operation. @@ -81,7 +86,7 @@ namespace Tgstation.Server.Host.Controllers /// The value of . /// The for the . /// The value of . - /// The containing value of . + /// The value of .. /// The value of . protected ApiController( IDatabaseContext databaseContext, @@ -92,11 +97,10 @@ namespace Tgstation.Server.Host.Controllers { DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext)); - ArgumentNullException.ThrowIfNull(apiHeadersProvider); + ApiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); Instance = AuthenticationContext?.InstancePermissionSet?.Instance; - ApiHeaders = apiHeadersProvider.ApiHeaders; this.requireHeaders = requireHeaders; } @@ -110,7 +114,7 @@ namespace Tgstation.Server.Host.Controllers if (ApiHeaders == null) { if (requireHeaders) - return HeadersIssue(false); + return HeadersIssue(ApiHeadersProvider.HeadersException); } else if (!ApiHeaders.Compatible()) return this.StatusCode( @@ -239,28 +243,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 - { - // TODO: Move this somewhere saner? - _ = 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/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 76598ef4f1..290049ca52 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -180,15 +180,15 @@ namespace Tgstation.Server.Host.Controllers try { // we only allow authorization header issues - var headers = new ApiHeaders(Request.GetTypedHeaders(), true); + var headers = ApiHeadersProvider.CreateAuthlessHeaders(); if (!headers.Compatible()) return this.StatusCode( HttpStatusCode.UpgradeRequired, new ErrorMessageResponse(ErrorCode.ApiMismatch)); } - catch (HeadersException) + catch (HeadersException ex) { - return HeadersIssue(true); + return HeadersIssue(ex); } } @@ -228,7 +228,7 @@ namespace Tgstation.Server.Host.Controllers if (ApiHeaders == null) { Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\"")); - return HeadersIssue(false); + return HeadersIssue(ApiHeadersProvider.HeadersException); } if (ApiHeaders.IsTokenAuthentication) diff --git a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs index c59d2d67d0..868a7683c7 100644 --- a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs +++ b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; + using Tgstation.Server.Api; namespace Tgstation.Server.Host.Utils @@ -10,29 +11,76 @@ namespace Tgstation.Server.Host.Utils sealed class ApiHeadersProvider : IApiHeadersProvider { /// - public ApiHeaders ApiHeaders { get; } + public ApiHeaders ApiHeaders => attemptedApiHeadersCreation + ? apiHeaders + : CreateApiHeaders(true); + + /// + public HeadersException HeadersException { get; private set; } + + /// + /// The for the . + /// + readonly IHttpContextAccessor httpContextAccessor; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Backing field for . + /// + ApiHeaders apiHeaders; + + /// + /// If populating was previously attempted. + /// + bool attemptedApiHeadersCreation; /// /// Initializes a new instance of the class. /// - /// The for accessing the . - /// The to write to. + /// The value of . + /// The value of . public ApiHeadersProvider(IHttpContextAccessor httpContextAccessor, ILogger logger) { - ArgumentNullException.ThrowIfNull(httpContextAccessor); + this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + /// + public ApiHeaders CreateAuthlessHeaders() => CreateApiHeaders(false); + + /// + /// Attempt to parse from the , optionally populating the properties. + /// + /// If the error should be ignored and / should be populated. + /// A newly parsed or if was set and the parse failed. + ApiHeaders CreateApiHeaders(bool includeAuthAndSetProperties) + { if (httpContextAccessor.HttpContext == null) throw new InvalidOperationException("httpContextAccessor has no HttpContext!"); var request = httpContextAccessor.HttpContext.Request; + var ignoreMissingAuth = !includeAuthAndSetProperties; + + if (includeAuthAndSetProperties) + attemptedApiHeadersCreation = true; + try { - ApiHeaders = new ApiHeaders(request.GetTypedHeaders()); + var headers = new ApiHeaders(request.GetTypedHeaders(), ignoreMissingAuth); + if (includeAuthAndSetProperties) + apiHeaders = headers; + + return headers; } - catch (HeadersException ex) + catch (HeadersException ex) when (includeAuthAndSetProperties) { - // we are not responsible for handling header validation issues - logger.LogTrace(ex, "Failed to validated API request headers!"); + logger.LogTrace(ex, "Failed to parse API headers!"); + HeadersException = ex; + return null; } } } diff --git a/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs index 31747862be..7de65834d3 100644 --- a/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs +++ b/src/Tgstation.Server.Host/Utils/IApiHeadersProvider.cs @@ -11,5 +11,18 @@ namespace Tgstation.Server.Host.Utils /// 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/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 5261d8719a..27329a1af4 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -43,7 +43,7 @@ namespace Tgstation.Server.Api.Tests { "User-Agent", userAgent } }; - return new ApiHeaders(new RequestHeaders(headers)); + return new ApiHeaders(new RequestHeaders(headers), false); }; var header = TestHeader(BrowserHeader); From dd8c8df35ffbb1628ec5b268b543dc8c2171a038 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 19:20:04 -0400 Subject: [PATCH 30/72] Fix `NotImplementedException` errors on non-github/gitlab deployments --- .../Deployment/Remote/NoOpRemoteDeploymentManager.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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; From e41e59a48aba2af750cad633f05bef7c1be1eb7c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 19:23:45 -0400 Subject: [PATCH 31/72] Remove unnecessary checks for non-existent `PosixSystemIdentity`s --- .../Controllers/ConfigurationController.cs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index ea26922fd8..37b7e89ed1 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -103,10 +103,6 @@ namespace Tgstation.Server.Host.Controllers AdditionalData = e.Message, }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } } /// @@ -150,10 +146,6 @@ namespace Tgstation.Server.Host.Controllers AdditionalData = e.Message, }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } } /// @@ -196,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( @@ -287,10 +274,6 @@ namespace Tgstation.Server.Host.Controllers Message = e.Message, }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } catch (UnauthorizedAccessException) { return Forbid(); @@ -335,10 +318,6 @@ namespace Tgstation.Server.Host.Controllers : Conflict(new ErrorMessageResponse(ErrorCode.ConfigurationDirectoryNotEmpty)); }); } - catch (NotImplementedException ex) - { - return RequiresPosixSystemIdentity(ex); - } catch (UnauthorizedAccessException) { return Forbid(); From 29579a351d70f433ca3752fb851d7e54baf5944b Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 29 Oct 2023 21:05:12 -0400 Subject: [PATCH 32/72] Fix JWT compliance issues. - `nbf` and `exp` should be numbers, not strings. - Parse JWT in `ApiHeaders` fully for errors. - Use `iat` and increment `nbf` by one second if equal and may trigger the `LastPasswordUpdate` bug. - Make client aware of this change and move the delay there. - Deprecated `TokenResponse.ExpiresAt`. --- src/Tgstation.Server.Api/ApiHeaders.cs | 42 ++++---------- .../Models/Response/TokenResponse.cs | 9 +++ .../Tgstation.Server.Api.csproj | 2 +- src/Tgstation.Server.Client/ApiClient.cs | 26 +++++++++ .../Controllers/HomeController.cs | 5 +- .../Security/ITokenFactory.cs | 10 +--- .../Security/TokenFactory.cs | 55 +++++++------------ .../Live/RawRequestTests.cs | 2 + 8 files changed, 75 insertions(+), 76 deletions(-) diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index b23d890abe..98f14b91db 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -9,12 +9,8 @@ using System.Text; using Microsoft.AspNetCore.Http.Headers; using Microsoft.Extensions.Primitives; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.Net.Http.Headers; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Models.Response; using Tgstation.Server.Api.Properties; @@ -274,36 +270,22 @@ namespace Tgstation.Server.Api OAuthCode = parameter; break; case BearerAuthenticationScheme: - var tokenSplits = parameter.Split('.'); - DateTimeOffset? expiresAt = null; - if (tokenSplits.Length != 3) - AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid JWT!"); - else - try - { - var bytes = Convert.FromBase64String(tokenSplits[1]); - var json = Encoding.UTF8.GetString(bytes); - var jwt = JsonConvert.DeserializeObject(json); - var nbf = jwt?.Value(JwtRegisteredClaimNames.Nbf); - - if (nbf != null) - if (Int64.TryParse(nbf, out var unixTimeSeconds)) - expiresAt = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds); - else - AddError(HeaderErrorTypes.AuthorizationInvalid, "'nbf' in JWT could not be parsed!"); - else - AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing 'nbf' in JWT payload!"); - } - catch - { - AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid JWT payload!"); - } - Token = new TokenResponse { Bearer = parameter, - ExpiresAt = expiresAt, }; + + 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!"; diff --git a/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs b/src/Tgstation.Server.Api/Models/Response/TokenResponse.cs index cb24b941a6..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. /// + [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/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index dfbd15a36d..3573822c52 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -26,7 +26,7 @@ - + diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index cb0bda67da..ccc5bb9e9b 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -336,6 +336,32 @@ namespace Tgstation.Server.Client if (authless) request.Headers.Remove(HeaderNames.Authorization); + else + { + var bearer = headersToUse.Token?.Bearer; + if (bearer != null) + { + try + { + 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); + } + } + catch (ArgumentException ex) when (ex is not ArgumentNullException) + { + // backwards compat, API <=9 put out invalid JWTs, remove in API 10 + } +#if DEBUG + if (ApiHeaders.Version.Major > 9) + throw new NotImplementedException(); +#endif + } + } if (fileDownload) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet)); diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 290049ca52..7a82131683 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -5,7 +5,6 @@ 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; @@ -373,11 +372,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.Value; + 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/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/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index 9ceac820ca..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); @@ -117,30 +95,37 @@ namespace Tgstation.Server.Host.Security // 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 (JwtRegisteredClaimNames.Sub, user.Id.Value.ToString(CultureInfo.InvariantCulture)), - new (JwtRegisteredClaimNames.Exp, expiry.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)), - new (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/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index 1845ee9eff..dae4ff1a34 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -200,11 +200,13 @@ 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)); From e1d359d2f7fa161c0462bcc4ec22e9949980cf3c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 30 Oct 2023 00:33:19 -0400 Subject: [PATCH 33/72] Change instance references to be a `ulong` --- .../Components/IInstanceReference.cs | 2 +- src/Tgstation.Server.Host/Components/InstanceWrapper.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) 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/InstanceWrapper.cs b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs index c8524b50e2..8f44a93b8a 100644 --- a/src/Tgstation.Server.Host/Components/InstanceWrapper.cs +++ b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs @@ -18,8 +18,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 +49,7 @@ namespace Tgstation.Server.Host.Components /// public InstanceWrapper() { - Uid = Guid.NewGuid(); + Uid = Interlocked.Increment(ref instanceWrapperInstances); } /// From f58ea64c96a1752342880a7dbae94d19ba397a79 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 30 Oct 2023 21:53:49 -0400 Subject: [PATCH 34/72] Remove spammy logging --- .../Utils/ApiHeadersProvider.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs index 868a7683c7..df19b90759 100644 --- a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs +++ b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs @@ -1,7 +1,6 @@ -using System; +using System; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; using Tgstation.Server.Api; @@ -23,11 +22,6 @@ namespace Tgstation.Server.Host.Utils /// readonly IHttpContextAccessor httpContextAccessor; - /// - /// The for the . - /// - readonly ILogger logger; - /// /// Backing field for . /// @@ -42,11 +36,9 @@ namespace Tgstation.Server.Host.Utils /// Initializes a new instance of the class. /// /// The value of . - /// The value of . - public ApiHeadersProvider(IHttpContextAccessor httpContextAccessor, ILogger logger) + public ApiHeadersProvider(IHttpContextAccessor httpContextAccessor) { this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -78,7 +70,6 @@ namespace Tgstation.Server.Host.Utils } catch (HeadersException ex) when (includeAuthAndSetProperties) { - logger.LogTrace(ex, "Failed to parse API headers!"); HeadersException = ex; return null; } From ba0ccdff53f0ffb4a63f37e7fa944e9ef1944515 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 31 Oct 2023 23:13:49 -0400 Subject: [PATCH 35/72] Set `sleep_offline` before `del(world)` --- src/DMAPI/tgs/core/datum.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DMAPI/tgs/core/datum.dm b/src/DMAPI/tgs/core/datum.dm index 935263cc95..8d402c9cfe 100644 --- a/src/DMAPI/tgs/core/datum.dm +++ b/src/DMAPI/tgs/core/datum.dm @@ -14,8 +14,8 @@ TGS_DEFINE_AND_SET_GLOBAL(tgs, null) /datum/tgs_api/proc/TerminateWorld() while(TRUE) TGS_DEBUG_LOG("About to terminate world. Tick: [world.time], sleep_offline: [world.sleep_offline]") - del(world) world.sleep_offline = FALSE // https://www.byond.com/forum/post/2894866 + del(world) sleep(1) TGS_DEBUG_LOG("BYOND DIDN'T TERMINATE THE WORLD!!! TICK IS: [world.time], sleep_offline: [world.sleep_offline]") From 242c74955d82f3e7ddae2cf4c43ccf973536f894 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Thu, 2 Nov 2023 22:58:56 -0400 Subject: [PATCH 36/72] Correct documentation for ApiHeaders.IsTokenAuthentication --- src/Tgstation.Server.Api/ApiHeaders.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 98f14b91db..498d475eaa 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -119,7 +119,7 @@ namespace Tgstation.Server.Api 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; From 1b1a91361c7b6c2d42cb70a440b8cd4693c827aa Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Thu, 2 Nov 2023 22:59:36 -0400 Subject: [PATCH 37/72] Correct documentation for ApiContoller.NotFound() --- src/Tgstation.Server.Host/Controllers/ApiController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index d0b7213427..399b28a5b0 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -188,7 +188,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Generic 404 response. /// - /// An with . + /// A with an appropriate . protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent)); /// From fb1aee74a1ecce0db985d423f4179d68b1b4bf58 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Thu, 2 Nov 2023 22:59:56 -0400 Subject: [PATCH 38/72] Fix using spacing --- src/Tgstation.Server.Host/Controllers/HomeController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 7a82131683..afa526ad92 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net; using System.Threading; @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; + using Octokit; using Tgstation.Server.Api; From d851df82764b6031fee35473a4ff5ac3ffccee70 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Thu, 2 Nov 2023 23:00:46 -0400 Subject: [PATCH 39/72] GET / with an Authorization header should fail if it's not valid --- .../Controllers/ApiController.cs | 8 +++++++- .../Controllers/HomeController.cs | 11 ++++++++++- tests/Tgstation.Server.Tests/Live/RawRequestTests.cs | 1 + 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 399b28a5b0..deeaa55dc3 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -191,6 +191,12 @@ namespace Tgstation.Server.Host.Controllers /// 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. /// diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index afa526ad92..6b1c2ebdf8 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Net; using System.Threading; @@ -164,6 +164,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 _)) @@ -190,7 +192,14 @@ namespace Tgstation.Server.Host.Controllers { return HeadersIssue(ex); } + + failIfUnauthed = Request.Headers.Authorization.Any(); } + else + failIfUnauthed = ApiHeaders.Token != null; + + if (failIfUnauthed && !AuthenticationContext.Valid) + return Unauthorized(); return Json(new ServerInformationResponse { diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index dae4ff1a34..7dc5d243f7 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -210,6 +210,7 @@ namespace Tgstation.Server.Tests.Live 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) From 61cae1a02d54c256c8c94f5913c30097c499b232 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 17:40:54 -0400 Subject: [PATCH 40/72] Fix nested default Linux log directory --- .../Configuration/FileLoggingConfiguration.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 48b08b5b48..ba1bb3c875 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -67,16 +67,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); } } } From fda26e1f153e891c7f4d83c9708238446f9bd099 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Fri, 3 Nov 2023 02:07:11 -0400 Subject: [PATCH 41/72] Fix issue with DMAPI validation shutdown timeouts - Increase grace period to 30s to avoid false positives. --- src/DMAPI/tgs/core/datum.dm | 1 + .../Components/Session/SessionController.cs | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/DMAPI/tgs/core/datum.dm b/src/DMAPI/tgs/core/datum.dm index 8d402c9cfe..07ce3b6845 100644 --- a/src/DMAPI/tgs/core/datum.dm +++ b/src/DMAPI/tgs/core/datum.dm @@ -16,6 +16,7 @@ TGS_DEFINE_AND_SET_GLOBAL(tgs, null) 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]") diff --git a/src/Tgstation.Server.Host/Components/Session/SessionController.cs b/src/Tgstation.Server.Host/Components/Session/SessionController.cs index 01ddcea2d4..bf0d22fac7 100644 --- a/src/Tgstation.Server.Host/Components/Session/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Session/SessionController.cs @@ -689,10 +689,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(); From d85e2b8d36ede54d8916d92c5911eb81e377173f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 31 Oct 2023 23:11:23 -0400 Subject: [PATCH 42/72] Add SignalR - Add one hub `/hubs/jobs`, strongly typed API included. - Adjust request pipeline to support SignalR. - Allow `Accept: text/event-stream` for SSE requests. - Add client library support for hubs. - Add integration tests. - Add IPermissionSetNotifyee to support dynamic changes based on perms. - Keep job state in `JobService` for pushing updates. --- src/Tgstation.Server.Api/ApiHeaders.cs | 30 +- .../Hubs/ConnectionAbortReason.cs | 18 ++ .../Hubs/IErrorHandlingHub.cs | 19 ++ src/Tgstation.Server.Api/Hubs/IJobsHub.cs | 21 ++ src/Tgstation.Server.Api/Routes.cs | 10 + src/Tgstation.Server.Client/ApiClient.cs | 217 ++++++++++--- .../Extensions/HubConnectionExtensions.cs | 104 +++++++ src/Tgstation.Server.Client/IApiClient.cs | 21 +- src/Tgstation.Server.Client/IServerClient.cs | 20 +- .../InfiniteThirtySecondMaxRetryPolicy.cs | 21 ++ src/Tgstation.Server.Client/ServerClient.cs | 14 +- .../ServerClientFactory.cs | 4 +- .../Tgstation.Server.Client.csproj | 7 + .../Components/InstanceWrapper.cs | 3 +- .../Controllers/ApiController.cs | 4 - .../Controllers/HomeController.cs | 8 +- .../Controllers/InstanceController.cs | 16 +- .../InstancePermissionSetController.cs | 15 +- .../Controllers/UserController.cs | 17 +- src/Tgstation.Server.Host/Core/Application.cs | 47 ++- .../ApplicationBuilderExtensions.cs | 29 ++ .../Extensions/ServiceCollectionExtensions.cs | 19 ++ src/Tgstation.Server.Host/Jobs/JobService.cs | 108 ++++++- src/Tgstation.Server.Host/Jobs/JobsHub.cs | 52 ++++ .../Jobs/JobsHubGroupMapper.cs | 172 ++++++++++ .../Security/AuthorizationContextHubFilter.cs | 89 ++++++ .../Security/IPermissionsUpdateNotifyee.cs | 37 +++ .../Tgstation.Server.Host.csproj | 2 + .../Utils/ApiHeadersProvider.cs | 24 +- .../Utils/SignalR/ComprehensiveHubContext.cs | 165 ++++++++++ .../Utils/SignalR/ConnectionMappingHub.cs | 60 ++++ .../SignalR/IConnectionMappedHubContext.cs | 43 +++ .../Utils/SignalR/IHubConnectionMapper.cs | 36 +++ .../TestApiHeaders.cs | 2 +- .../Tgstation.Server.Client.Tests.csproj | 4 + .../Live/Instance/JobsHubTests.cs | 293 ++++++++++++++++++ .../Live/RateLimitRetryingApiClient.cs | 14 +- .../Live/RateLimitRetryingApiClientFactory.cs | 7 +- .../Live/RawRequestTests.cs | 132 +++++++- .../Live/TestLiveServer.cs | 94 ++++-- .../Tgstation.Server.Tests.csproj | 4 + .../Tgstation.Server.Migrator.csproj | 3 +- 42 files changed, 1873 insertions(+), 132 deletions(-) create mode 100644 src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs create mode 100644 src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs create mode 100644 src/Tgstation.Server.Api/Hubs/IJobsHub.cs create mode 100644 src/Tgstation.Server.Client/Extensions/HubConnectionExtensions.cs create mode 100644 src/Tgstation.Server.Client/InfiniteThirtySecondMaxRetryPolicy.cs create mode 100644 src/Tgstation.Server.Host/Jobs/JobsHub.cs create mode 100644 src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs create mode 100644 src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs create mode 100644 src/Tgstation.Server.Host/Security/IPermissionsUpdateNotifyee.cs create mode 100644 src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs create mode 100644 src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs create mode 100644 src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs create mode 100644 src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs create mode 100644 tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 498d475eaa..01e61cb373 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -58,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. /// @@ -184,9 +189,10 @@ 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) + public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth, bool allowEventStreamAccept) { if (requestHeaders == null) throw new ArgumentNullException(nameof(requestHeaders)); @@ -207,8 +213,12 @@ namespace Tgstation.Server.Api } var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJsonMime); - if (!requestHeaders.Accept.Any(x => jsonAccept.IsSubsetOf(x))) - AddError(HeaderErrorTypes.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(HeaderErrorTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!"); @@ -386,6 +396,20 @@ namespace Tgstation.Server.Api 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 . /// diff --git a/src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs b/src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs new file mode 100644 index 0000000000..05b1cac9b4 --- /dev/null +++ b/src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs @@ -0,0 +1,18 @@ +namespace Tgstation.Server.Api.Hubs +{ + /// + /// The reason an aborts a connection. + /// + public enum ConnectionAbortReason + { + /// + /// The provided token is no longer authenticated or authorized to keep the connection. + /// + TokenInvalid, + + /// + /// The server is restarting. + /// + ServerRestart, + } +} diff --git a/src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs b/src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs new file mode 100644 index 0000000000..844c621d4f --- /dev/null +++ b/src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Api.Hubs +{ + /// + /// Hub for handling communication errors. + /// + public interface IErrorHandlingHub + { + /// + /// Called if a hub connection or call is attempted with an invalid or unauthorized token. After calling this, the connection is aborted. + /// + /// The . + /// The for the operation. + /// A representing the running operation. + Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Api/Hubs/IJobsHub.cs b/src/Tgstation.Server.Api/Hubs/IJobsHub.cs new file mode 100644 index 0000000000..01b4aec8bd --- /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 : IErrorHandlingHub + { + /// + /// 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/Routes.cs b/src/Tgstation.Server.Api/Routes.cs index ab8bd05c23..b62d781604 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.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index ccc5bb9e9b..ed48bc7bc7 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,128 @@ 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(); + + HubConnection? hubConnection = null; + var hubConnectionBuilder = new HubConnectionBuilder() + .AddNewtonsoftJsonProtocol(options => + { + options.PayloadSerializerSettings = SerializerSettings; + }) + .WithAutomaticReconnect(retryPolicy) + .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 +464,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, @@ -322,7 +482,7 @@ namespace Tgstation.Server.Client 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)) { @@ -418,38 +578,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); - } - catch (ClientException) - { - return false; - } - finally - { - semaphoreSlim.Release(); - } - - return true; - } +#pragma warning restore CA1506 /// /// Main request method. @@ -475,7 +604,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/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/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 5826b76bdc..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 @@ -59,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 2d464ec0ec..155198996a 100644 --- a/src/Tgstation.Server.Client/ServerClientFactory.cs +++ b/src/Tgstation.Server.Client/ServerClientFactory.cs @@ -120,7 +120,7 @@ namespace Tgstation.Server.Client TimeSpan? timeout = null, CancellationToken cancellationToken = default) { - using var api = ApiClientFactory.CreateApiClient( + await using var api = ApiClientFactory.CreateApiClient( host, new ApiHeaders( productHeaderValue, @@ -162,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); diff --git a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj index 6868098fa6..e6c2aaaea7 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/InstanceWrapper.cs b/src/Tgstation.Server.Host/Components/InstanceWrapper.cs index 8f44a93b8a..1e1359c3e9 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.Byond; diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index deeaa55dc3..b3136a115f 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -116,10 +116,6 @@ namespace Tgstation.Server.Host.Controllers if (requireHeaders) return HeadersIssue(ApiHeadersProvider.HeadersException); } - else if (!ApiHeaders.Compatible()) - return this.StatusCode( - HttpStatusCode.UpgradeRequired, - new ErrorMessageResponse(ErrorCode.ApiMismatch)); var errorCase = await ValidateRequest(cancellationToken); if (errorCase != null) diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 6b1c2ebdf8..b33c5bf77d 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; @@ -21,7 +20,6 @@ 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; @@ -182,11 +180,7 @@ namespace Tgstation.Server.Host.Controllers try { // we only allow authorization header issues - var headers = ApiHeadersProvider.CreateAuthlessHeaders(); - if (!headers.Compatible()) - return this.StatusCode( - HttpStatusCode.UpgradeRequired, - new ErrorMessageResponse(ErrorCode.ApiMismatch)); + ApiHeadersProvider.CreateAuthlessHeaders(); } catch (HeadersException ex) { diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index d4ae712281..7310f56f1c 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -68,6 +68,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IPortAllocator portAllocator; + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + /// /// The for the . /// @@ -88,7 +93,8 @@ namespace Tgstation.Server.Host.Controllers /// 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 . @@ -101,6 +107,7 @@ namespace Tgstation.Server.Host.Controllers IIOManager ioManager, IPortAllocator portAllocator, IPlatformIdentifier platformIdentifier, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, IOptions generalConfigurationOptions, IOptions swarmConfigurationOptions, IApiHeadersProvider apiHeaders) @@ -116,6 +123,8 @@ namespace Tgstation.Server.Host.Controllers 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)); } @@ -267,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); @@ -770,6 +783,7 @@ namespace Tgstation.Server.Host.Controllers { permissionSetToModify ??= new InstancePermissionSet() { + PermissionSet = AuthenticationContext.PermissionSet, PermissionSetId = AuthenticationContext.PermissionSet.Id.Value, }; permissionSetToModify.ByondRights = RightsHelper.AllRights(); diff --git a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs index 5c1a91e6ba..30a1827de3 100644 --- a/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstancePermissionSetController.cs @@ -30,19 +30,26 @@ 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, IAuthenticationContext authenticationContext, ILogger logger, IInstanceManager instanceManager, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, IApiHeadersProvider apiHeaders) : base( databaseContext, @@ -51,6 +58,7 @@ namespace Tgstation.Server.Host.Controllers instanceManager, apiHeaders) { + this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee)); } /// @@ -76,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); @@ -112,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 diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 38c7504302..08c61e05fe 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -40,6 +40,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly ICryptographySuite cryptographySuite; + /// + /// The for the . + /// + readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee; + /// /// The for the . /// @@ -52,6 +57,7 @@ namespace Tgstation.Server.Host.Controllers /// The for the . /// The value of . /// The value of . + /// The value of . /// The for the . /// The containing the value of . /// The for the . @@ -60,6 +66,7 @@ namespace Tgstation.Server.Host.Controllers IAuthenticationContext authenticationContext, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, + IPermissionsUpdateNotifyee permissionsUpdateNotifyee, ILogger logger, IOptions generalConfigurationOptions, IApiHeadersProvider apiHeaders) @@ -72,6 +79,7 @@ namespace Tgstation.Server.Host.Controllers { 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)); } @@ -243,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 @@ -315,6 +327,9 @@ namespace Tgstation.Server.Host.Controllers 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 || callerAdministrationRights.HasFlag(AdministrationRights.ReadUsers); diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 7253e90d68..b97a21600f 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -14,9 +14,12 @@ 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; @@ -31,6 +34,7 @@ using Serilog.Formatting.Display; using Serilog.Sinks.Elasticsearch; using Tgstation.Server.Api; +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Common.Http; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Byond; @@ -250,6 +254,18 @@ namespace Tgstation.Server.Host.Core 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")); @@ -386,6 +402,7 @@ namespace Tgstation.Server.Host.Core // configure root services services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); @@ -501,6 +518,9 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Web control panel disabled!"); #endif + // validate the API version + applicationBuilder.UseApiCompatibility(); + // authenticate JWT tokens using our security pipeline if present, returns 401 if bad applicationBuilder.UseAuthentication(); @@ -513,6 +533,17 @@ namespace Tgstation.Server.Host.Core // 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(); }); @@ -541,10 +572,18 @@ namespace Tgstation.Server.Host.Core services.AddScoped(); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); - services.AddScoped(provider => - { - return provider.GetRequiredService().CurrentAuthenticationContext; - }); + + // 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(); diff --git a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs index ae8eb65ae6..55086228dc 100644 --- a/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ApplicationBuilderExtensions.cs @@ -119,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/ServiceCollectionExtensions.cs b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs index c4d7e029b5..6693ca670e 100644 --- a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs @@ -11,11 +11,13 @@ using Serilog; using Serilog.Configuration; using Serilog.Sinks.Elasticsearch; +using Tgstation.Server.Api.Hubs; using Tgstation.Server.Host.Components.Chat.Providers; 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 +222,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, IErrorHandlingHub + { + 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/JobService.cs b/src/Tgstation.Server.Host/Jobs/JobService.cs index b5029e2fbe..92d5096542 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 { + /// + /// The maximum rate at which hub clients can receive updates. + /// + const int MaxHubUpdatesPerSecond = 4; + + /// + /// The for the . + /// + readonly IConnectionMappedHubContext hub; + /// /// The for the . /// @@ -63,17 +79,21 @@ 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(); activationTcs = new TaskCompletionSource(); synchronizationLock = new object(); @@ -269,13 +289,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; } @@ -292,23 +311,60 @@ namespace Tgstation.Server.Host.Jobs /// /// 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) + { + 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 (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); + } + } + try { - var oldJob = job; - job = new Job { Id = oldJob.Id }; - void UpdateProgress(string stage, double? progress) { if (progress.HasValue @@ -319,19 +375,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); } } var instanceCoreProvider = await activationTcs.Task.WaitAsync(cancellationToken); + QueueHubUpdate(job.ToApi()); logger.LogTrace("Starting job..."); await operation( - instanceCoreProvider.GetInstance(oldJob.Instance), + instanceCoreProvider.GetInstance(job.Instance), databaseContextFactory, job, new JobProgressReporter( @@ -377,6 +440,31 @@ namespace Tgstation.Server.Host.Jobs 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()); + }); + + try + { + await hubUpdatesTask; + } + catch (Exception ex) + { + logger.LogError(ex, "Error in hub updates chain task!"); + } + return result; } finally 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..a05d3283b5 --- /dev/null +++ b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs @@ -0,0 +1,172 @@ +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.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 + { + /// + /// The for the . + /// + readonly IConnectionMappedHubContext hub; + + /// + /// The for the . + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The value of . + /// The value of . + public JobsHubGroupMapper(IConnectionMappedHubContext hub, IDatabaseContextFactory databaseContextFactory, ILogger logger) + { + this.hub = hub ?? throw new ArgumentNullException(nameof(hub)); + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + 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"); + return hub.NotifyAndAbortUnauthedConnections(user, cancellationToken); + } + + /// + 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); + } + + /// + /// Implementation of . + /// + /// The to map the groups for. + /// The for the operation. + /// A resulting in an of the group names the user belongs in. + async ValueTask> MapConnectionGroups(IAuthenticationContext authenticationContext, 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)); + + return permedInstanceIds.Select(JobsHub.HubGroupName); + } + + /// + /// 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/Security/AuthorizationContextHubFilter.cs b/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs new file mode 100644 index 0000000000..7820d66494 --- /dev/null +++ b/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +using Tgstation.Server.Api.Hubs; + +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 (await ValidateAuthenticationContext(context.Hub)) + await next(context); + } + + /// + public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) + { + ArgumentNullException.ThrowIfNull(invocationContext); + if (await 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. + async ValueTask 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); + + if (caller is not IErrorHandlingHub specifiedHub) + throw new InvalidOperationException("This filter only supports IErrorHandlingHubs"); + + await specifiedHub.AbortingConnection(ConnectionAbortReason.TokenInvalid, hub.Context.ConnectionAborted); + hub.Context.Abort(); + return false; + } + } +} 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/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index e156db8d18..f1f8d02ca7 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -78,6 +78,8 @@ + + diff --git a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs index df19b90759..56aa288619 100644 --- a/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs +++ b/src/Tgstation.Server.Host/Utils/ApiHeadersProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.AspNetCore.Http; @@ -12,7 +12,7 @@ namespace Tgstation.Server.Host.Utils /// public ApiHeaders ApiHeaders => attemptedApiHeadersCreation ? apiHeaders - : CreateApiHeaders(true); + : CreateApiHeaders(false); /// public HeadersException HeadersException { get; private set; } @@ -42,33 +42,31 @@ namespace Tgstation.Server.Host.Utils } /// - public ApiHeaders CreateAuthlessHeaders() => CreateApiHeaders(false); + public ApiHeaders CreateAuthlessHeaders() => CreateApiHeaders(true); /// /// Attempt to parse from the , optionally populating the properties. /// - /// If the error should be ignored and / should be populated. - /// A newly parsed or if was set and the parse failed. - ApiHeaders CreateApiHeaders(bool includeAuthAndSetProperties) + /// 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 request = httpContextAccessor.HttpContext.Request; - var ignoreMissingAuth = !includeAuthAndSetProperties; - - if (includeAuthAndSetProperties) + var typedHeaders = httpContextAccessor.HttpContext.Request.GetTypedHeaders(); + if (!authless) attemptedApiHeadersCreation = true; try { - var headers = new ApiHeaders(request.GetTypedHeaders(), ignoreMissingAuth); - if (includeAuthAndSetProperties) + var headers = new ApiHeaders(typedHeaders, authless, !authless); + if (!authless) apiHeaders = headers; return headers; } - catch (HeadersException ex) when (includeAuthAndSetProperties) + catch (HeadersException ex) when (!authless) { HeadersException = ex; return null; 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..411c424f08 --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs @@ -0,0 +1,165 @@ +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.Api.Hubs; +using Tgstation.Server.Common.Extensions; +using Tgstation.Server.Host.Core; +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 interface for implementing methods. + sealed class ComprehensiveHubContext : IConnectionMappedHubContext, IHubConnectionMapper, IRestartHandler + where THub : ConnectionMappingHub + where THubMethods : class, IErrorHandlingHub + { + /// + 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>> OnConnectionMapGroups; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + /// The to with. + /// The value of . + public ComprehensiveHubContext( + IHubContext wrappedHubContext, + IServerControl serverControl, + ILogger> logger) + { + this.wrappedHubContext = wrappedHubContext ?? throw new ArgumentNullException(nameof(wrappedHubContext)); + ArgumentNullException.ThrowIfNull(serverControl); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + userConnections = new ConcurrentDictionary>(); + + serverControl.RegisterForRestart(this); + } + + /// + public List UserConnectionIds(User user) + { + ArgumentNullException.ThrowIfNull(user); + var connectionIds = userConnections.GetOrAdd(user.Id.Value, _ => new Dictionary()); + lock (connectionIds) + return connectionIds.Keys.ToList(); + } + + /// + public async 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 mappedGroupsTask = OnConnectionMapGroups(authenticationContext, cancellationToken); + userConnections.AddOrUpdate( + userId, + _ => new Dictionary + { + { context.ConnectionId, context }, + }, + (_, old) => + { + lock (old) + old[context.ConnectionId] = context; + + return old; + }); + + var mappedGroups = await mappedGroupsTask; + await Task.WhenAll( + mappedGroups.Select( + group => hub.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken))); + } + + /// + 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 ValueTask NotifyAndAbortUnauthedConnections(User user, CancellationToken cancellationToken) + { + 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; + }); + + async ValueTask NotifyAndAbortConnection(HubCallerContext context) + { + await Clients + .Client(context.ConnectionId) + .AbortingConnection(ConnectionAbortReason.TokenInvalid, cancellationToken); + context.Abort(); + } + + return ValueTaskExtensions.WhenAll(connections.Select(NotifyAndAbortConnection)); + } + + /// + public async ValueTask HandleRestart(Version updateVersion, bool handlerMayDelayShutdownWithExtremelyLongRunningTasks, CancellationToken cancellationToken) + { + logger.LogTrace("HandleRestart. {connectionCount} active connections", userConnections.Count); + await Clients.All.AbortingConnection(ConnectionAbortReason.ServerRestart, cancellationToken); + userConnections.Clear(); + } + } +} 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..5f81010e8b --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Api.Hubs; +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 interface for implementing methods. + [TgsAuthorize] + abstract class ConnectionMappingHub : Hub + where TChildHub : ConnectionMappingHub + where THubMethods : class, IErrorHandlingHub + { + /// + /// 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..d44ad96108 --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Api.Hubs; +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, IErrorHandlingHub + { + /// + /// Called when a user connects. Should return an of hub group names the given belongs in. + /// + event Func>> 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); + + /// + /// Calls with on and aborts the connections associated with the given . + /// + /// The to abort the connections of. + /// The for the operation. + /// A representing the running operation. + ValueTask NotifyAndAbortUnauthedConnections(User user, CancellationToken cancellationToken); + } +} 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..f941ac28fe --- /dev/null +++ b/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR; + +using Tgstation.Server.Api.Hubs; +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 interface for implementing methods. + interface IHubConnectionMapper + where THub : ConnectionMappingHub + where THubMethods : class, IErrorHandlingHub + { + /// + /// 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/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs index 27329a1af4..8d6808f1b2 100644 --- a/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs +++ b/tests/Tgstation.Server.Api.Tests/TestApiHeaders.cs @@ -43,7 +43,7 @@ namespace Tgstation.Server.Api.Tests { "User-Agent", userAgent } }; - return new ApiHeaders(new RequestHeaders(headers), false); + return new ApiHeaders(new RequestHeaders(headers), false, false); }; var header = TestHeader(BrowserHeader); 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..98434096f7 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.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs new file mode 100644 index 0000000000..f78352772d --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -0,0 +1,293 @@ +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.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 + { + const int ActiveConnections = 2; + + readonly IServerClient permedUser; + readonly IServerClient permlessUser; + + readonly TaskCompletionSource finishTcs; + + readonly ConcurrentDictionary seenJobs; + + readonly HashSet permlessSeenJobs; + + HubConnection conn1, conn2; + int expectedReboots; + 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 Func Error { get; set; } + + public Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken) + => Error(reason, cancellationToken); + + public Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken) + { + Callback(job); + return Task.CompletedTask; + } + } + + public async Task Run(CancellationToken cancellationToken) + { + var neverReceiverTcs = new TaskCompletionSource(); + var neverReceiver = new ShouldNeverReceiveUpdates() + { + Callback = job => + { + if (!permlessIsPermed) + neverReceiverTcs.TrySetException(new Exception($"ShouldNeverReceiveUpdates received an update for job {job.Id}!")); + else + lock (permlessSeenJobs) + permlessSeenJobs.Add(job.Id.Value); + }, + Error = AbortingConnection, + }; + + 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; + }; + + var completedTask = await Task.WhenAny(finishTcs.Task, neverReceiverTcs.Task); + await completedTask; + } + + neverReceiverTcs.TrySetResult(); + await neverReceiverTcs.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.Description.StartsWith("Reconnect chat bot") + || job.Description.StartsWith("Instance startup watchdog reattach") + || job.Description.StartsWith("Instance startup watchdog launch"); + 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(); + Assert.AreEqual(0, expectedReboots); + } + + public void ExpectShutdown() + { + Assert.AreEqual(0, Interlocked.Exchange(ref expectedReboots, ActiveConnections)); + Assert.AreEqual(HubConnectionState.Connected, conn1.State); + Assert.AreEqual(HubConnectionState.Connected, conn2.State); + } + + public async ValueTask WaitForReconnect(CancellationToken cancellationToken) + { + Assert.AreEqual(0, expectedReboots); + 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(); + + public Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken) + { + try + { + Assert.AreEqual(ConnectionAbortReason.ServerRestart, reason); + var remaining = Interlocked.Decrement(ref expectedReboots); + Assert.IsTrue(remaining >= 0); + } + catch (Exception ex) + { + finishTcs.TrySetException(ex); + } + return Task.CompletedTask; + } + } +} 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 7dc5d243f7..bb028397b4 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; @@ -346,12 +356,132 @@ namespace Tgstation.Server.Tests.Live } } + class FuncProxiedJobsHub : IJobsHub + { + public Func ProxyFunc { get; set; } + public Func ErrorFunc { get; set; } + + public Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken) + => ErrorFunc(reason); + + 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); + }); + + hubConnectionBuilder.ConfigureLogging( + loggingBuilder => + { + loggingBuilder.SetMinimumLevel(LogLevel.Trace); + loggingBuilder.AddConsole(); + loggingBuilder + .Services + .TryAddEnumerable( + ServiceDescriptor.Singleton()); + }); + + var proxy = new FuncProxiedJobsHub(); + var errorTcs = new TaskCompletionSource(); + proxy.ErrorFunc = reason => + { + errorTcs.SetException(new Exception($"Aborted: {reason}")); + return Task.CompletedTask; + }; + + 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); + + Assert.IsFalse(errorTcs.Task.IsCompleted); + + 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)) + { + errorTcs = new TaskCompletionSource(); + await using var testUserConn1 = await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); + + Assert.IsFalse(errorTcs.Task.IsCompleted); + + await serverClient.Users.Update(new UserUpdateRequest + { + Id = testUser.Id, + Enabled = false, + }, cancellationToken); + + // need a second here + for (var i = 0; i < 10 && !errorTcs.Task.IsCompleted; ++i) + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + + Assert.IsTrue(errorTcs.Task.IsCompleted); + + errorTcs = new TaskCompletionSource(); + await using var testUserConn2 = await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); + for (var i = 0; i < 10 && !errorTcs.Task.IsCompleted; ++i) + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + Assert.IsTrue(errorTcs.Task.IsCompleted); + await Assert.ThrowsExceptionAsync(() => errorTcs.Task); + } + 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 b7a430b810..1f51de5f59 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -255,7 +255,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()) @@ -336,7 +336,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 @@ -392,7 +392,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 @@ -442,7 +442,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); @@ -544,9 +544,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); @@ -610,12 +610,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 @@ -685,8 +685,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 @@ -701,7 +701,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() { @@ -815,9 +815,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); @@ -897,7 +897,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( @@ -934,7 +934,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( @@ -991,7 +991,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); @@ -1273,7 +1273,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) { @@ -1302,21 +1324,23 @@ 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); instance = await instanceManagerTest.CreateTestInstance("LiveTestsInstance", cancellationToken); var compatInstance = await compatInstanceTask; 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); @@ -1329,7 +1353,7 @@ namespace Tgstation.Server.Tests.Live new PlatformIdentifier().IsWindows ? 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, @@ -1364,7 +1388,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)); @@ -1412,8 +1437,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); @@ -1478,6 +1504,7 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(WatchdogStatus.Offline, dd.Status); + jobsHubTest.ExpectShutdown(); await adminClient.Administration.Restart(cancellationToken); } @@ -1496,7 +1523,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(); @@ -1515,10 +1541,11 @@ namespace Tgstation.Server.Tests.Live serverTask = server.Run(cancellationToken).AsTask(); long expectedCompileJobId, expectedStaged; var edgeByond = await ByondTest.GetEdgeVersion(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); @@ -1554,6 +1581,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); } @@ -1562,10 +1590,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); @@ -1581,6 +1610,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); } } diff --git a/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj b/tests/Tgstation.Server.Tests/Tgstation.Server.Tests.csproj index 82c98aa9f0..2ed1cd139b 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 a74f718d7c..4d4dc7f61d 100644 --- a/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj +++ b/tools/Tgstation.Server.Migrator/Tgstation.Server.Migrator.csproj @@ -1,4 +1,4 @@ - + @@ -13,6 +13,7 @@ + From 5b1e123e64f73e666ec3f2327816af222a541d5a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 19:22:57 -0400 Subject: [PATCH 43/72] Add `DeploymentActivation` event --- .../Components/Events/EventType.cs | 6 ++++++ .../Components/Watchdog/AdvancedWatchdog.cs | 11 +++-------- .../Components/Watchdog/BasicWatchdog.cs | 5 ++++- .../Components/Watchdog/WatchdogBase.cs | 19 +++++++++++++++++++ .../Components/Watchdog/WatchdogFactory.cs | 1 + 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/Tgstation.Server.Host/Components/Events/EventType.cs b/src/Tgstation.Server.Host/Components/Events/EventType.cs index 0a54518a24..2b9575c856 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. + /// + [EventScript("DeploymentActivation")] + DeploymentActivation, } } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/AdvancedWatchdog.cs index 35d3e508e7..7878854458 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(); diff --git a/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/BasicWatchdog.cs index 815f87fa9b..d915a8b935 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 3847e26077..5b379d0043 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)); @@ -679,6 +687,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 +704,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, From 6fb9ba6f372f09604a512c30e9bafaec055e1cbc Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 19:43:33 -0400 Subject: [PATCH 44/72] Check registered jobs have instance and user IDs --- src/Tgstation.Server.Host/Jobs/JobService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Jobs/JobService.cs b/src/Tgstation.Server.Host/Jobs/JobService.cs index 92d5096542..2212bba37d 100644 --- a/src/Tgstation.Server.Host/Jobs/JobService.cs +++ b/src/Tgstation.Server.Host/Jobs/JobService.cs @@ -120,7 +120,7 @@ namespace Tgstation.Server.Host.Jobs job.Instance = new Models.Instance { - Id = job.Instance.Id, + Id = job.Instance.Id ?? throw new InvalidOperationException("Instance associated with job does not have an Id!"), }; databaseContext.Instances.Attach(job.Instance); @@ -131,7 +131,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); From 385771f477eaf1933f349947b06eec40ad5289cb Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 20:38:47 -0400 Subject: [PATCH 45/72] Change this `.OfType()` to `.Cast()` --- .../Components/StaticFiles/Configuration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs b/src/Tgstation.Server.Host/Components/StaticFiles/Configuration.cs index 380e0e02ea..0a9a356e77 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, From 189d594b4cb7729a7aab297b68a0cc922e6eb27f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 20:47:14 -0400 Subject: [PATCH 46/72] Update nuget packages --- src/Tgstation.Server.Host/Tgstation.Server.Host.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index f1f8d02ca7..4d35ef1280 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -73,7 +73,7 @@ - + @@ -97,7 +97,7 @@ - + From 85e1c0d5f1e69abfbecfece78125b402e314d5e2 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 20:47:41 -0400 Subject: [PATCH 47/72] Fix dotnet-ef version being out of sync --- src/Tgstation.Server.Host/.config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/.config/dotnet-tools.json b/src/Tgstation.Server.Host/.config/dotnet-tools.json index c418dc80b9..37aa7721e3 100644 --- a/src/Tgstation.Server.Host/.config/dotnet-tools.json +++ b/src/Tgstation.Server.Host/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "7.0.11", + "version": "7.0.13", "commands": [ "dotnet-ef" ] From ebd09d7e86882b181710375748262331b62d4010 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 21:03:55 -0400 Subject: [PATCH 48/72] Add `JobCode`s to strongly type jobs - Default `0` for legacy jobs. - Standardize job creation prior to registration. - Add `RightsHelper.TypeToRight()`. --- .../Models/Internal/Job.cs | 7 + src/Tgstation.Server.Api/Models/JobCode.cs | 112 ++ .../Rights/RightsHelper.cs | 10 + .../Components/Chat/Providers/Provider.cs | 9 +- .../Components/Deployment/DreamMaker.cs | 11 +- .../Components/Instance.cs | 21 +- .../Components/InstanceManager.cs | 5 +- .../Components/Watchdog/WatchdogBase.cs | 17 +- .../Controllers/ByondController.cs | 26 +- .../Controllers/DreamDaemonController.cs | 27 +- .../Controllers/DreamMakerController.cs | 9 +- .../Controllers/InstanceController.cs | 16 +- .../Controllers/RepositoryController.cs | 30 +- .../Database/DatabaseContext.cs | 17 +- .../20231105004801_MSAddJobCodes.Designer.cs | 1075 ++++++++++++++++ .../20231105004801_MSAddJobCodes.cs | 33 + .../20231105004808_MYAddJobCodes.Designer.cs | 1109 +++++++++++++++++ .../20231105004808_MYAddJobCodes.cs | 33 + .../20231105004814_PGAddJobCodes.Designer.cs | 1069 ++++++++++++++++ .../20231105004814_PGAddJobCodes.cs | 33 + .../20231105004820_SLAddJobCodes.Designer.cs | 1041 ++++++++++++++++ .../20231105004820_SLAddJobCodes.cs | 33 + .../MySqlDatabaseContextModelSnapshot.cs | 6 +- ...PostgresSqlDatabaseContextModelSnapshot.cs | 6 +- .../SqlServerDatabaseContextModelSnapshot.cs | 6 +- .../SqliteDatabaseContextModelSnapshot.cs | 6 +- src/Tgstation.Server.Host/Jobs/JobService.cs | 18 +- src/Tgstation.Server.Host/Models/Job.cs | 85 +- .../Models/TestJobCode.cs | 27 + .../Live/Instance/JobsRequiredTest.cs | 10 + 30 files changed, 4752 insertions(+), 155 deletions(-) create mode 100644 src/Tgstation.Server.Api/Models/JobCode.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.Designer.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004801_MSAddJobCodes.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.Designer.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004808_MYAddJobCodes.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.Designer.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004814_PGAddJobCodes.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.Designer.cs create mode 100644 src/Tgstation.Server.Host/Database/Migrations/20231105004820_SLAddJobCodes.cs create mode 100644 tests/Tgstation.Server.Api.Tests/Models/TestJobCode.cs 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..072c71eda8 --- /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 BYOND version is being installed. + /// + [Description("Install BYOND version")] + ByondOfficialInstall, + + /// + /// When a new BYOND version is being installed. + /// + [Description("Install custom BYOND version")] + ByondCustomInstall, + + /// + /// When an installed BYOND version is being deleted. + /// + [Description("Delete installed BYOND version")] + ByondDelete, + + /// + /// 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 DreamDaemon")] + 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/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index eb33954a48..73e948bdbb 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.Host/Components/Chat/Providers/Provider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs index 88cd5b2e2c..a6496c1cda 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs @@ -273,13 +273,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers connectNow = false; if (!Connected) { - var job = new Job - { - Description = $"Reconnect chat bot: {ChatBot.Name}", - CancelRight = (ulong)ChatBotRights.WriteEnabled, - CancelRightsType = RightsType.ChatBots, - Instance = ChatBot.Instance, - }; + var job = 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/DreamMaker.cs b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs index c561e25939..8792e2f304 100644 --- a/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Deployment/DreamMaker.cs @@ -337,10 +337,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 { @@ -431,10 +428,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); diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index 9843e5c0b8..de7e70d796 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 ec66af63b1..86dbad5332 100644 --- a/src/Tgstation.Server.Host/Components/InstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/InstanceManager.cs @@ -351,10 +351,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/Watchdog/WatchdogBase.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs index 5b379d0043..025f3f27a8 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogBase.cs @@ -377,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) => diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 41cccc1584..10a02eee86 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -195,14 +195,14 @@ namespace Tgstation.Server.Host.Controllers Instance.Id); // run the install through the job manager - var job = new Job - { - Description = $"Install {(!uploadingZip ? String.Empty : "custom ")}BYOND version {version}", - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Byond, - CancelRight = (ulong)ByondRights.CancelInstall, - Instance = Instance, - }; + var job = Job.Create( + uploadingZip + ? JobCode.ByondCustomInstall + : JobCode.ByondOfficialInstall, + AuthenticationContext.User, + Instance, + ByondRights.CancelInstall); + job.Description += $" {version}"; IFileUploadTicket fileUploadTicket = null; if (uploadingZip) @@ -304,14 +304,8 @@ namespace Tgstation.Server.Host.Controllers var isCustomVersion = version.Build != -1; // run the install through the job manager - var job = new Job - { - Description = $"Delete installed BYOND version {version}", - StartedBy = AuthenticationContext.User, - CancelRightsType = RightsType.Byond, - CancelRight = (ulong)(isCustomVersion ? ByondRights.InstallOfficialOrChangeActiveVersion : ByondRights.InstallCustomVersion), - Instance = Instance, - }; + var job = Job.Create(JobCode.ByondDelete, AuthenticationContext.User, Instance, isCustomVersion ? ByondRights.InstallOfficialOrChangeActiveVersion : ByondRights.InstallCustomVersion); + job.Description += $" {version}"; await jobManager.RegisterOperation( job, diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 5bceb479d5..097e0377d3 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -87,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 DreamDaemon", - 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), @@ -270,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; @@ -303,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 105a64d8b9..714713763f 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -143,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, diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 7310f56f1c..342d9193b6 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -355,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) { @@ -490,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, diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index d701142bfd..37766ce0fd 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -139,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); @@ -222,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, @@ -454,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/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index fbd7d0a38c..51e10a4e4b 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -378,22 +378,22 @@ namespace Tgstation.Server.Host.Database /// /// Used by unit tests to remind us to setup the correct MSSQL migration downgrades. /// - internal static readonly Type MSLatestMigration = typeof(MSAddMapThreads); + internal static readonly Type MSLatestMigration = typeof(MSAddJobCodes); /// /// Used by unit tests to remind us to setup the correct MYSQL migration downgrades. /// - internal static readonly Type MYLatestMigration = typeof(MYAddMapThreads); + internal static readonly Type MYLatestMigration = typeof(MYAddJobCodes); /// /// Used by unit tests to remind us to setup the correct PostgresSQL migration downgrades. /// - internal static readonly Type PGLatestMigration = typeof(PGAddMapThreads); + internal static readonly Type PGLatestMigration = typeof(PGAddJobCodes); /// /// Used by unit tests to remind us to setup the correct SQLite migration downgrades. /// - internal static readonly Type SLLatestMigration = typeof(SLAddMapThreads); + internal static readonly Type SLLatestMigration = typeof(SLAddJobCodes); /// #pragma warning disable CA1502 // Cyclomatic complexity @@ -422,6 +422,15 @@ namespace Tgstation.Server.Host.Database string BadDatabaseType() => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType)); + if (targetVersion < new Version(5, 17, 0)) + targetMigration = currentDatabaseType switch + { + DatabaseType.MySql => nameof(MYAddMapThreads), + DatabaseType.PostgresSql => nameof(PGAddMapThreads), + DatabaseType.SqlServer => nameof(MSAddMapThreads), + 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/MySqlDatabaseContextModelSnapshot.cs b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs index f70cac88ac..c5e6c701d7 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/MySqlDatabaseContextModelSnapshot.cs @@ -11,12 +11,11 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(MySqlDatabaseContext))] partial class MySqlDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.7") + .HasAnnotation("ProductVersion", "7.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => @@ -416,6 +415,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 aa74a6f86d..f550058f18 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/PostgresSqlDatabaseContextModelSnapshot.cs @@ -11,12 +11,11 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(PostgresSqlDatabaseContext))] partial class PostgresSqlDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.7") + .HasAnnotation("ProductVersion", "7.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -396,6 +395,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 4534c99cf6..f8a83396a9 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqlServerDatabaseContextModelSnapshot.cs @@ -11,12 +11,11 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(SqlServerDatabaseContext))] partial class SqlServerDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.7") + .HasAnnotation("ProductVersion", "7.0.13") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -399,6 +398,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 b952648287..c5f426c470 100644 --- a/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs +++ b/src/Tgstation.Server.Host/Database/Migrations/SqliteDatabaseContextModelSnapshot.cs @@ -11,11 +11,10 @@ namespace Tgstation.Server.Host.Database.Migrations [DbContext(typeof(SqliteDatabaseContext))] partial class SqliteDatabaseContextModelSnapshot : ModelSnapshot { - /// protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.13"); modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b => { @@ -386,6 +385,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/Jobs/JobService.cs b/src/Tgstation.Server.Host/Jobs/JobService.cs index 2212bba37d..b1aa9c1e0a 100644 --- a/src/Tgstation.Server.Host/Jobs/JobService.cs +++ b/src/Tgstation.Server.Host/Jobs/JobService.cs @@ -120,7 +120,7 @@ namespace Tgstation.Server.Host.Jobs job.Instance = new Models.Instance { - Id = job.Instance.Id ?? throw new InvalidOperationException("Instance associated with job does not have an Id!"), + Id = job.Instance.Id.Value, }; databaseContext.Instances.Attach(job.Instance); @@ -172,14 +172,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; @@ -201,10 +201,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)) @@ -234,7 +231,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); @@ -425,10 +422,7 @@ namespace Tgstation.Server.Host.Jobs await databaseContextFactory.UseContext(async databaseContext => { - var attachedJob = new Job - { - Id = job.Id, - }; + var attachedJob = new Job(job.Id.Value); databaseContext.Jobs.Attach(attachedJob); attachedJob.StoppedAt = DateTimeOffset.UtcNow; diff --git a/src/Tgstation.Server.Host/Models/Job.cs b/src/Tgstation.Server.Host/Models/Job.cs index a05b83b811..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,88 @@ 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, 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.Tests/Live/Instance/JobsRequiredTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs index c561d7abde..51e5587c64 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsRequiredTest.cs @@ -22,11 +22,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); @@ -49,11 +53,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); @@ -66,6 +74,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); From edcd26e44f62ff02fe0701644885a35f887c72ab Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 23:27:57 -0400 Subject: [PATCH 49/72] Allow GET `/Jobs` request spam again Now that we have a hub as a better alternative, it should be less prevalent. --- .../Controllers/ApiController.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index b3136a115f..ffa4e84960 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -154,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( From ec81fcdf4125595991e071dba792ea4d490128e1 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 23:28:09 -0400 Subject: [PATCH 50/72] Remove no-op call --- src/Tgstation.Server.Host/Controllers/HomeController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index b33c5bf77d..659a379591 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -246,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) From dd2e2644778397fe02a501080149fdfd1644f19f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 23:29:26 -0400 Subject: [PATCH 51/72] Document new authentication pipeline Also fix location of `TgsAuthorizeAttribute` .md documentation. --- .../Controllers/README.md | 1 - src/Tgstation.Server.Host/Security/README.md | 70 ++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) 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/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index 80f5afaccf..da4d16464e 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -1,12 +1,78 @@ # 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. + - 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 deprecated 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 invokes `IErrorHandlingHub.AbortingConnection` with `ConnectionAbortReason.TokenInvalid` on the client before aborting 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. From 31ac130b45317e4aa48399560a27a299d2a9b58e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 23:31:33 -0400 Subject: [PATCH 52/72] Version bumps - Api - ApiLibrary - ClientLibrary - Core --- build/Version.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build/Version.props b/build/Version.props index ecaea3d8d9..f63dc186b2 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,12 +3,12 @@ - 5.16.4 + 5.17.0 4.7.1 - 9.12.0 + 9.13.0 7.0.0 - 11.1.2 - 13.0.0 + 12.0.0 + 14.0.0 6.6.2 5.6.2 1.4.0 From 9044eb041d1e4062245de3d6f4543dd617e2a381 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sat, 4 Nov 2023 23:34:19 -0400 Subject: [PATCH 53/72] Properly document `DeploymentActivation` event --- src/Tgstation.Server.Host/Components/Events/EventType.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Components/Events/EventType.cs b/src/Tgstation.Server.Host/Components/Events/EventType.cs index 2b9575c856..ef7571ac83 100644 --- a/src/Tgstation.Server.Host/Components/Events/EventType.cs +++ b/src/Tgstation.Server.Host/Components/Events/EventType.cs @@ -163,7 +163,7 @@ DeploymentCleanup, /// - /// Whenever a deployment is about to be used by the game server. May fire multiple times per deployment. + /// 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, From cab8ce68f5e560ac0ef39c1c821b7fe2aa8336d4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 08:40:18 -0500 Subject: [PATCH 54/72] Simplify this job type check using new JobCodes --- tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index f78352772d..ccd158b372 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -9,6 +9,7 @@ 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; @@ -186,9 +187,9 @@ namespace Tgstation.Server.Tests.Live.Instance } else { - var wasMissableJob = job.Description.StartsWith("Reconnect chat bot") - || job.Description.StartsWith("Instance startup watchdog reattach") - || job.Description.StartsWith("Instance startup watchdog launch"); + var wasMissableJob = job.JobCode == JobCode.ReconnectChatBot + || job.JobCode == JobCode.StartupWatchdogLaunch + || job.JobCode == JobCode.StartupWatchdogReattach; Assert.IsTrue(wasMissableJob); ++missableMissedJobs; } From 2c82da46f6a14efd6b1df5bfbc9db8492337d1d3 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 09:00:44 -0500 Subject: [PATCH 55/72] Simplify logic flow in JobsHubTests --- .../Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index ccd158b372..228d9296ce 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -92,13 +92,12 @@ namespace Tgstation.Server.Tests.Live.Instance public async Task Run(CancellationToken cancellationToken) { - var neverReceiverTcs = new TaskCompletionSource(); var neverReceiver = new ShouldNeverReceiveUpdates() { Callback = job => { if (!permlessIsPermed) - neverReceiverTcs.TrySetException(new Exception($"ShouldNeverReceiveUpdates received an update for job {job.Id}!")); + finishTcs.TrySetException(new Exception($"ShouldNeverReceiveUpdates received an update for job {job.Id}!")); else lock (permlessSeenJobs) permlessSeenJobs.Add(job.Id.Value); @@ -131,13 +130,9 @@ namespace Tgstation.Server.Tests.Live.Instance return Task.CompletedTask; }; - var completedTask = await Task.WhenAny(finishTcs.Task, neverReceiverTcs.Task); - await completedTask; + await finishTcs.Task; } - neverReceiverTcs.TrySetResult(); - await neverReceiverTcs.Task; - var allInstances = await permedUser.Instances.List(null, cancellationToken); async ValueTask> CheckInstance(InstanceResponse instance) From d3563394fb0e5bda5825b1b575657db321513c3a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 09:01:20 -0500 Subject: [PATCH 56/72] `OperationCanceledException`s are not test errors --- tests/Tgstation.Server.Tests/Live/TestLiveServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 1f51de5f59..0ef060c2f3 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -1621,7 +1621,7 @@ namespace Tgstation.Server.Tests.Live Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex.ErrorCode}: {ex.Message}\n{ex.AdditionalServerData}"); throw; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { Console.WriteLine($"[{DateTimeOffset.UtcNow}] TEST ERROR: {ex}"); throw; From ed8453f08ebc0513963f6c4733c2e5bb886e450f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 09:38:57 -0500 Subject: [PATCH 57/72] Remove hub abort notifications Because SignalR buffers messages, we can't guarantee these will be delivered before the connection is aborted. We'll have to rely on the client not being pants-on-head. --- .../Hubs/ConnectionAbortReason.cs | 18 ------- .../Hubs/IErrorHandlingHub.cs | 19 ------- src/Tgstation.Server.Api/Hubs/IJobsHub.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 3 +- .../Jobs/JobsHubGroupMapper.cs | 3 +- .../Security/AuthorizationContextHubFilter.cs | 12 ++--- src/Tgstation.Server.Host/Security/README.md | 2 +- .../Utils/SignalR/ComprehensiveHubContext.cs | 33 ++----------- .../Utils/SignalR/ConnectionMappingHub.cs | 5 +- .../SignalR/IConnectionMappedHubContext.cs | 9 ++-- .../Utils/SignalR/IHubConnectionMapper.cs | 5 +- .../Live/Instance/JobsHubTests.cs | 26 ---------- .../Live/RawRequestTests.cs | 49 ++++++------------- 13 files changed, 35 insertions(+), 151 deletions(-) delete mode 100644 src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs delete mode 100644 src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs diff --git a/src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs b/src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs deleted file mode 100644 index 05b1cac9b4..0000000000 --- a/src/Tgstation.Server.Api/Hubs/ConnectionAbortReason.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Tgstation.Server.Api.Hubs -{ - /// - /// The reason an aborts a connection. - /// - public enum ConnectionAbortReason - { - /// - /// The provided token is no longer authenticated or authorized to keep the connection. - /// - TokenInvalid, - - /// - /// The server is restarting. - /// - ServerRestart, - } -} diff --git a/src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs b/src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs deleted file mode 100644 index 844c621d4f..0000000000 --- a/src/Tgstation.Server.Api/Hubs/IErrorHandlingHub.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Tgstation.Server.Api.Hubs -{ - /// - /// Hub for handling communication errors. - /// - public interface IErrorHandlingHub - { - /// - /// Called if a hub connection or call is attempted with an invalid or unauthorized token. After calling this, the connection is aborted. - /// - /// The . - /// The for the operation. - /// A representing the running operation. - Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Api/Hubs/IJobsHub.cs b/src/Tgstation.Server.Api/Hubs/IJobsHub.cs index 01b4aec8bd..4c595362cb 100644 --- a/src/Tgstation.Server.Api/Hubs/IJobsHub.cs +++ b/src/Tgstation.Server.Api/Hubs/IJobsHub.cs @@ -8,7 +8,7 @@ namespace Tgstation.Server.Api.Hubs /// /// SignalR client methods for receiving s. /// - public interface IJobsHub : IErrorHandlingHub + public interface IJobsHub { /// /// Push a update to the client. diff --git a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs index 6693ca670e..c90433f1ff 100644 --- a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs @@ -11,7 +11,6 @@ using Serilog; using Serilog.Configuration; using Serilog.Sinks.Elasticsearch; -using Tgstation.Server.Api.Hubs; using Tgstation.Server.Host.Components.Chat.Providers; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.IO; @@ -230,7 +229,7 @@ namespace Tgstation.Server.Host.Extensions /// The to add the to. public static void AddHub(this IServiceCollection services) where THub : ConnectionMappingHub - where THubMethods : class, IErrorHandlingHub + where THubMethods : class { ArgumentNullException.ThrowIfNull(services); diff --git a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs index a05d3283b5..4f46c90014 100644 --- a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs +++ b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs @@ -71,7 +71,8 @@ namespace Tgstation.Server.Host.Jobs throw new InvalidOperationException("user.Id was null!"); logger.LogTrace("UserDisabled"); - return hub.NotifyAndAbortUnauthedConnections(user, cancellationToken); + hub.AbortUnauthedConnections(user); + return ValueTask.CompletedTask; } /// diff --git a/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs b/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs index 7820d66494..38360cab9b 100644 --- a/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs +++ b/src/Tgstation.Server.Host/Security/AuthorizationContextHubFilter.cs @@ -5,8 +5,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -using Tgstation.Server.Api.Hubs; - namespace Tgstation.Server.Host.Security { /// @@ -41,7 +39,7 @@ namespace Tgstation.Server.Host.Security public async Task OnConnectedAsync(HubLifetimeContext context, Func next) { ArgumentNullException.ThrowIfNull(context); - if (await ValidateAuthenticationContext(context.Hub)) + if (ValidateAuthenticationContext(context.Hub)) await next(context); } @@ -49,7 +47,7 @@ namespace Tgstation.Server.Host.Security public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) { ArgumentNullException.ThrowIfNull(invocationContext); - if (await ValidateAuthenticationContext(invocationContext.Hub)) + if (ValidateAuthenticationContext(invocationContext.Hub)) return await next(invocationContext); return null; @@ -60,7 +58,7 @@ namespace Tgstation.Server.Host.Security /// /// The current . /// if the hub call should continue, if it shouldn't and has been aborted. - async ValueTask ValidateAuthenticationContext(Hub hub) + bool ValidateAuthenticationContext(Hub hub) { if (!authenticationContext.Valid) logger.LogTrace("The token for connection {connectionId} is no longer authenticated! Aborting...", hub.Context.ConnectionId); @@ -78,10 +76,6 @@ namespace Tgstation.Server.Host.Security var callerProperty = clients.GetType().GetProperty(nameof(hub.Clients.Caller)); var caller = callerProperty.GetValue(clients); - if (caller is not IErrorHandlingHub specifiedHub) - throw new InvalidOperationException("This filter only supports IErrorHandlingHubs"); - - await specifiedHub.AbortingConnection(ConnectionAbortReason.TokenInvalid, hub.Context.ConnectionAborted); hub.Context.Abort(); return false; } diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index da4d16464e..104db33ea4 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -67,7 +67,7 @@ 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 invokes `IErrorHandlingHub.AbortingConnection` with `ConnectionAbortReason.TokenInvalid` on the client before aborting the connection. + - 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: diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs index 411c424f08..9e2b053a65 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs @@ -8,9 +8,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -using Tgstation.Server.Api.Hubs; -using Tgstation.Server.Common.Extensions; -using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -20,10 +17,10 @@ namespace Tgstation.Server.Host.Utils.SignalR /// An implementation of with connection ID mapping. /// /// The the is for. - /// The interface for implementing methods. - sealed class ComprehensiveHubContext : IConnectionMappedHubContext, IHubConnectionMapper, IRestartHandler + /// The for implementing methods. + sealed class ComprehensiveHubContext : IConnectionMappedHubContext, IHubConnectionMapper where THub : ConnectionMappingHub - where THubMethods : class, IErrorHandlingHub + where THubMethods : class { /// public IHubClients Clients => wrappedHubContext.Clients; @@ -53,20 +50,15 @@ namespace Tgstation.Server.Host.Utils.SignalR /// Initializes a new instance of the class. /// /// The value of . - /// The to with. /// The value of . public ComprehensiveHubContext( IHubContext wrappedHubContext, - IServerControl serverControl, ILogger> logger) { this.wrappedHubContext = wrappedHubContext ?? throw new ArgumentNullException(nameof(wrappedHubContext)); - ArgumentNullException.ThrowIfNull(serverControl); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); userConnections = new ConcurrentDictionary>(); - - serverControl.RegisterForRestart(this); } /// @@ -123,7 +115,7 @@ namespace Tgstation.Server.Host.Utils.SignalR } /// - public ValueTask NotifyAndAbortUnauthedConnections(User user, CancellationToken cancellationToken) + public void AbortUnauthedConnections(User user) { ArgumentNullException.ThrowIfNull(user); logger.LogTrace("NotifyAndAbortUnauthedConnections. UID {userId}", user.Id.Value); @@ -143,23 +135,8 @@ namespace Tgstation.Server.Host.Utils.SignalR return old; }); - async ValueTask NotifyAndAbortConnection(HubCallerContext context) - { - await Clients - .Client(context.ConnectionId) - .AbortingConnection(ConnectionAbortReason.TokenInvalid, cancellationToken); + foreach (var context in connections) context.Abort(); - } - - return ValueTaskExtensions.WhenAll(connections.Select(NotifyAndAbortConnection)); - } - - /// - public async ValueTask HandleRestart(Version updateVersion, bool handlerMayDelayShutdownWithExtremelyLongRunningTasks, CancellationToken cancellationToken) - { - logger.LogTrace("HandleRestart. {connectionCount} active connections", userConnections.Count); - await Clients.All.AbortingConnection(ConnectionAbortReason.ServerRestart, cancellationToken); - userConnections.Clear(); } } } diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs b/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs index 5f81010e8b..05e2eb8742 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/ConnectionMappingHub.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; -using Tgstation.Server.Api.Hubs; using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Utils.SignalR @@ -13,11 +12,11 @@ namespace Tgstation.Server.Host.Utils.SignalR /// Base for s that want to map their connection IDs to s. /// /// The child inheriting from the . - /// The interface for implementing methods. + /// The for implementing methods. [TgsAuthorize] abstract class ConnectionMappingHub : Hub where TChildHub : ConnectionMappingHub - where THubMethods : class, IErrorHandlingHub + where THubMethods : class { /// /// The used to map connections. diff --git a/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs index d44ad96108..3877881702 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; -using Tgstation.Server.Api.Hubs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -18,7 +17,7 @@ namespace Tgstation.Server.Host.Utils.SignalR /// The interface for implementing methods. interface IConnectionMappedHubContext : IHubContext where THub : Hub - where THubMethods : class, IErrorHandlingHub + where THubMethods : class { /// /// Called when a user connects. Should return an of hub group names the given belongs in. @@ -33,11 +32,9 @@ namespace Tgstation.Server.Host.Utils.SignalR List UserConnectionIds(User user); /// - /// Calls with on and aborts the connections associated with the given . + /// Aborts the connections associated with the given . /// /// The to abort the connections of. - /// The for the operation. - /// A representing the running operation. - ValueTask NotifyAndAbortUnauthedConnections(User user, CancellationToken cancellationToken); + void AbortUnauthedConnections(User user); } } diff --git a/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs b/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs index f941ac28fe..4521c87fcc 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/IHubConnectionMapper.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; -using Tgstation.Server.Api.Hubs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -13,10 +12,10 @@ namespace Tgstation.Server.Host.Utils.SignalR /// Handles mapping connection IDs to s for a given . /// /// The whose connections are being mapped. - /// The interface for implementing methods. + /// The for implementing methods. interface IHubConnectionMapper where THub : ConnectionMappingHub - where THubMethods : class, IErrorHandlingHub + where THubMethods : class { /// /// To be called when a hub connection is made. diff --git a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs index 228d9296ce..bf683503c1 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/JobsHubTests.cs @@ -19,8 +19,6 @@ namespace Tgstation.Server.Tests.Live.Instance { sealed class JobsHubTests : IJobsHub { - const int ActiveConnections = 2; - readonly IServerClient permedUser; readonly IServerClient permlessUser; @@ -31,7 +29,6 @@ namespace Tgstation.Server.Tests.Live.Instance readonly HashSet permlessSeenJobs; HubConnection conn1, conn2; - int expectedReboots; bool permlessIsPermed; long? permlessPsId; @@ -78,10 +75,6 @@ namespace Tgstation.Server.Tests.Live.Instance class ShouldNeverReceiveUpdates : IJobsHub { public Action Callback { get; set; } - public Func Error { get; set; } - - public Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken) - => Error(reason, cancellationToken); public Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken) { @@ -102,7 +95,6 @@ namespace Tgstation.Server.Tests.Live.Instance lock (permlessSeenJobs) permlessSeenJobs.Add(job.Id.Value); }, - Error = AbortingConnection, }; await using (conn1 = (HubConnection)await permedUser.SubscribeToJobUpdates( @@ -208,19 +200,16 @@ namespace Tgstation.Server.Tests.Live.Instance Assert.AreEqual(HubConnectionState.Connected, conn3.State); await permlessUser.DisposeAsync(); await permedUser.DisposeAsync(); - Assert.AreEqual(0, expectedReboots); } public void ExpectShutdown() { - Assert.AreEqual(0, Interlocked.Exchange(ref expectedReboots, ActiveConnections)); Assert.AreEqual(HubConnectionState.Connected, conn1.State); Assert.AreEqual(HubConnectionState.Connected, conn2.State); } public async ValueTask WaitForReconnect(CancellationToken cancellationToken) { - Assert.AreEqual(0, expectedReboots); await Task.WhenAll(conn1.StopAsync(cancellationToken), conn2.StopAsync(cancellationToken)); Assert.AreEqual(HubConnectionState.Disconnected, conn1.State); @@ -270,20 +259,5 @@ namespace Tgstation.Server.Tests.Live.Instance } public void CompleteNow() => finishTcs.TrySetResult(); - - public Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken) - { - try - { - Assert.AreEqual(ConnectionAbortReason.ServerRestart, reason); - var remaining = Interlocked.Decrement(ref expectedReboots); - Assert.IsTrue(remaining >= 0); - } - catch (Exception ex) - { - finishTcs.TrySetException(ex); - } - return Task.CompletedTask; - } } } diff --git a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs index bb028397b4..7e4a101001 100644 --- a/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs +++ b/tests/Tgstation.Server.Tests/Live/RawRequestTests.cs @@ -359,10 +359,6 @@ namespace Tgstation.Server.Tests.Live class FuncProxiedJobsHub : IJobsHub { public Func ProxyFunc { get; set; } - public Func ErrorFunc { get; set; } - - public Task AbortingConnection(ConnectionAbortReason reason, CancellationToken cancellationToken) - => ErrorFunc(reason); public Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken) => ProxyFunc(job, cancellationToken); @@ -402,13 +398,6 @@ namespace Tgstation.Server.Tests.Live }); var proxy = new FuncProxiedJobsHub(); - var errorTcs = new TaskCompletionSource(); - proxy.ErrorFunc = reason => - { - errorTcs.SetException(new Exception($"Aborted: {reason}")); - return Task.CompletedTask; - }; - HubConnection hubConnection; HardFailLoggerProvider.BlockFails = true; try @@ -431,8 +420,6 @@ namespace Tgstation.Server.Tests.Live Assert.AreEqual(HubConnectionState.Disconnected, hubConnection.State); - Assert.IsFalse(errorTcs.Task.IsCompleted); - var createRequest = new UserCreateRequest { Enabled = true, @@ -441,33 +428,27 @@ namespace Tgstation.Server.Tests.Live }; 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 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 { - errorTcs = new TaskCompletionSource(); - await using var testUserConn1 = await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); + Id = testUser.Id, + Enabled = false, + }, cancellationToken); - Assert.IsFalse(errorTcs.Task.IsCompleted); + // need a second here + for (var i = 0; i < 10 && testUserConn1.State == HubConnectionState.Connected; ++i) + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - await serverClient.Users.Update(new UserUpdateRequest - { - Id = testUser.Id, - Enabled = false, - }, cancellationToken); + Assert.AreNotEqual(HubConnectionState.Connected, testUserConn1.State); - // need a second here - for (var i = 0; i < 10 && !errorTcs.Task.IsCompleted; ++i) - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + await using var testUserConn2 = (HubConnection)await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); - Assert.IsTrue(errorTcs.Task.IsCompleted); + for (var i = 0; i < 10 && testUserConn2.State == HubConnectionState.Connected; ++i) + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - errorTcs = new TaskCompletionSource(); - await using var testUserConn2 = await testUserClient.SubscribeToJobUpdates(proxy, cancellationToken: cancellationToken); - for (var i = 0; i < 10 && !errorTcs.Task.IsCompleted; ++i) - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - } - - Assert.IsTrue(errorTcs.Task.IsCompleted); - await Assert.ThrowsExceptionAsync(() => errorTcs.Task); + Assert.AreNotEqual(HubConnectionState.Connected, testUserConn2.State); } finally { From fa13869ea269409d122e2c24aad2e1ce882efdb4 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 11:23:47 -0500 Subject: [PATCH 58/72] Ensure that SignalR works with the webpanel - Fix issue with CORS preventing static file browsing. - AllowCredentials in CORS. Switch from `AllowAnyOrigin` to a wildcard matching `Func` to bypass the CORS specification that says you can't do that. - Add workaround for legacy SignalR `access_token` query string. - Get token `nbf` from claims rather than through the composition root. --- src/Tgstation.Server.Host/Core/Application.cs | 71 +++++++++++-------- ...thenticationContextClaimsTransformation.cs | 20 +++++- .../Security/AuthenticationContextFactory.cs | 24 +------ .../Security/IAuthenticationContextFactory.cs | 6 +- 4 files changed, 66 insertions(+), 55 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index b97a21600f..922011a9ac 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -473,33 +473,6 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Swagger API generation enabled"); } - // 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.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) { @@ -518,6 +491,34 @@ namespace Tgstation.Server.Host.Core logger.LogTrace("Web control panel disabled!"); #endif + // 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(); @@ -596,10 +597,20 @@ namespace Tgstation.Server.Host.Core jwtBearerOptions.TokenValidationParameters = tokenFactory?.ValidationParameters ?? throw new InvalidOperationException("tokenFactory not initialized!"); jwtBearerOptions.Events = new JwtBearerEvents { - OnTokenValidated = tokenValidatedContext => + OnMessageReceived = context => { - var acf = tokenValidatedContext.HttpContext.RequestServices.GetRequiredService(); - acf.SetTokenNbf(tokenValidatedContext.SecurityToken.ValidFrom); + 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/Security/AuthenticationContextClaimsTransformation.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs index 6cd5f30305..4f02bb0b0d 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextClaimsTransformation.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; +using Microsoft.IdentityModel.Tokens; using Tgstation.Server.Api; using Tgstation.Server.Api.Rights; @@ -48,7 +49,7 @@ namespace Tgstation.Server.Host.Security var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub); if (userIdClaim == default) - throw new InvalidOperationException("Missing required claim!"); + throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!"); long userId; try @@ -60,9 +61,26 @@ namespace Tgstation.Server.Host.Security 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) diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs index 3b5035e69c..f39daaa989 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContextFactory.cs @@ -46,11 +46,6 @@ namespace Tgstation.Server.Host.Security /// readonly AuthenticationContext currentAuthenticationContext; - /// - /// The the request's token must be valid after. - /// - DateTimeOffset? validAfter; - /// /// 1 if was initialized, 0 otherwise. /// @@ -80,27 +75,12 @@ namespace Tgstation.Server.Host.Security /// public void Dispose() => currentAuthenticationContext.Dispose(); - /// - /// Populate with a given . - /// - /// The an issued token is not valid before. - public void SetTokenNbf(DateTimeOffset tokenNbf) - { - if (validAfter.HasValue) - throw new InvalidOperationException("SetTokenNbf called multiple times!"); - - validAfter = tokenNbf; - } - /// - public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken) + public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken) { if (Interlocked.Exchange(ref initialized, 1) != 0) throw new InvalidOperationException("Authentication context has already been loaded"); - if (!validAfter.HasValue) - throw new InvalidOperationException("SetTokenNbf has not been called!"); - var user = await databaseContext .Users .AsQueryable() @@ -122,7 +102,7 @@ namespace Tgstation.Server.Host.Security systemIdentity = identityCache.LoadCachedIdentity(user); else { - if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter.Value) + if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > notBefore) { logger.LogDebug("Rejecting token for user {userId} created before last password update: {lastPasswordUpdate}", userId, user.LastPasswordUpdate.Value); return currentAuthenticationContext; diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs index 0233700585..cb70a50b18 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContextFactory.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; namespace Tgstation.Server.Host.Security @@ -13,8 +14,9 @@ namespace Tgstation.Server.Host.Security /// /// The of the . /// The of the for the operation. + /// The the login must not be from before. /// The for the operation. /// A resulting in the created . - ValueTask CreateAuthenticationContext(long userId, long? instanceId, CancellationToken cancellationToken); + ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken); } } From b79d02c77ab98670ca15ba12cb813b8fe9791537 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 11:56:43 -0500 Subject: [PATCH 59/72] Push an update of all active jobs to all clients when a new one connects I know this is annoying in that we only need to target the connected client, but it's so much simpler this way. How big of an update can it even be if it's only including active jobs? --- src/Tgstation.Server.Host/Core/Application.cs | 4 +- .../Jobs/IJobsHubUpdater.cs | 13 ++ src/Tgstation.Server.Host/Jobs/JobService.cs | 144 +++++++++++------- .../Jobs/JobsHubGroupMapper.cs | 27 +++- .../Utils/SignalR/ComprehensiveHubContext.cs | 16 +- .../SignalR/IConnectionMappedHubContext.cs | 9 +- 6 files changed, 145 insertions(+), 68 deletions(-) create mode 100644 src/Tgstation.Server.Host/Jobs/IJobsHubUpdater.cs diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 922011a9ac..a331feeb9f 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -400,7 +400,9 @@ 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(); 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 b1aa9c1e0a..7793eb582b 100644 --- a/src/Tgstation.Server.Host/Jobs/JobService.cs +++ b/src/Tgstation.Server.Host/Jobs/JobService.cs @@ -24,7 +24,7 @@ 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. @@ -56,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. /// @@ -95,6 +100,7 @@ namespace Tgstation.Server.Host.Jobs this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); jobs = new Dictionary(); + hubUpdateActions = new Dictionary(); activationTcs = new TaskCompletionSource(); synchronizationLock = new object(); addCancelLock = new object(); @@ -305,6 +311,14 @@ namespace Tgstation.Server.Host.Jobs activationTcs.SetResult(instanceCoreProvider); } + /// + public void QueueActiveJobUpdates() + { + lock (hubUpdateActions) + foreach (var action in hubUpdateActions.Values) + action(); + } + /// /// Runner for s. /// @@ -325,39 +339,51 @@ namespace Tgstation.Server.Host.Jobs var result = false; Stopwatch stopwatch = null; - void QueueHubUpdate(JobResponse update) + void QueueHubUpdate(JobResponse update, bool final) { - var currentUpdatesTask = hubUpdatesTask; - async Task ChainHubUpdate() + void NextUpdate() { - 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 (stopwatch != null) + var currentUpdatesTask = hubUpdatesTask; + async Task ChainHubUpdate() { - Monitor.Enter(stopwatch); - enteredLock = stopwatch; - if (stopwatch.ElapsedMilliseconds * MaxHubUpdatesPerSecond < 1) - return; // don't spam client + await currentUpdatesTask; + + // DCT: Cancellation token is for job, operation should always run + await hub + .Clients + .Group(JobsHub.HubGroupName(job)) + .ReceiveJobUpdate(update, CancellationToken.None); } - hubUpdatesTask = ChainHubUpdate(); - stopwatch = Stopwatch.StartNew(); - } - finally - { - if (enteredLock != null) - Monitor.Exit(enteredLock); + Stopwatch enteredLock = null; + try + { + if (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; + + NextUpdate(); } try @@ -382,12 +408,12 @@ namespace Tgstation.Server.Host.Jobs var updatedJob = job.ToApi(); updatedJob.Stage = stage; updatedJob.Progress = newProgress; - QueueHubUpdate(updatedJob); + QueueHubUpdate(updatedJob, false); } } var instanceCoreProvider = await activationTcs.Task.WaitAsync(cancellationToken); - QueueHubUpdate(job.ToApi()); + QueueHubUpdate(job.ToApi(), false); logger.LogTrace("Starting job..."); await operation( @@ -420,35 +446,45 @@ namespace Tgstation.Server.Host.Jobs LogException(e); } - await databaseContextFactory.UseContext(async databaseContext => + try { - var attachedJob = new Job(job.Id.Value); + await databaseContextFactory.UseContext(async databaseContext => + { + 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 => + // 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 { - // 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()); - }); + lock (hubUpdateActions) + hubUpdateActions.Remove(job.Id.Value); + + throw; + } try { diff --git a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs index 4f46c90014..77ccf99b0b 100644 --- a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs +++ b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs @@ -27,10 +27,15 @@ namespace Tgstation.Server.Host.Jobs readonly IConnectionMappedHubContext hub; /// - /// The for the . + /// The for the . /// readonly IDatabaseContextFactory databaseContextFactory; + /// + /// The for the . + /// + readonly IJobsHubUpdater jobsHubUpdater; + /// /// The for the . /// @@ -41,11 +46,17 @@ namespace Tgstation.Server.Host.Jobs /// /// The value of . /// The value of . + /// The value of . /// The value of . - public JobsHubGroupMapper(IConnectionMappedHubContext hub, IDatabaseContextFactory databaseContextFactory, ILogger logger) + 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; @@ -89,9 +100,13 @@ namespace Tgstation.Server.Host.Jobs /// 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, CancellationToken cancellationToken) + async ValueTask MapConnectionGroups( + IAuthenticationContext authenticationContext, + Func, Task> mappingFunc, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(authenticationContext); @@ -105,7 +120,11 @@ namespace Tgstation.Server.Host.Jobs .Select(ips => ips.Id) .ToListAsync(cancellationToken)); - return permedInstanceIds.Select(JobsHub.HubGroupName); + await mappingFunc( + permedInstanceIds.Select( + JobsHub.HubGroupName)); + + jobsHubUpdater.QueueActiveJobUpdates(); } /// diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs index 9e2b053a65..7999908052 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs @@ -44,7 +44,7 @@ namespace Tgstation.Server.Host.Utils.SignalR readonly ConcurrentDictionary> userConnections; /// - public event Func>> OnConnectionMapGroups; + public event Func, Task>, CancellationToken, ValueTask> OnConnectionMapGroups; /// /// Initializes a new instance of the class. @@ -71,7 +71,7 @@ namespace Tgstation.Server.Host.Utils.SignalR } /// - public async ValueTask UserConnected(IAuthenticationContext authenticationContext, THub hub, CancellationToken cancellationToken) + public ValueTask UserConnected(IAuthenticationContext authenticationContext, THub hub, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(authenticationContext); ArgumentNullException.ThrowIfNull(hub); @@ -83,7 +83,12 @@ namespace Tgstation.Server.Host.Utils.SignalR userId, context.ConnectionId); - var mappedGroupsTask = OnConnectionMapGroups(authenticationContext, cancellationToken); + var mappingTask = OnConnectionMapGroups( + authenticationContext, + mappedGroups => Task.WhenAll( + mappedGroups.Select( + group => hub.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken))), + cancellationToken); userConnections.AddOrUpdate( userId, _ => new Dictionary @@ -98,10 +103,7 @@ namespace Tgstation.Server.Host.Utils.SignalR return old; }); - var mappedGroups = await mappedGroupsTask; - await Task.WhenAll( - mappedGroups.Select( - group => hub.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken))); + return mappingTask; } /// diff --git a/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs index 3877881702..101e8e599e 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/IConnectionMappedHubContext.cs @@ -20,9 +20,14 @@ namespace Tgstation.Server.Host.Utils.SignalR where THubMethods : class { /// - /// Called when a user connects. Should return an of hub group names the given belongs in. + /// 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>> OnConnectionMapGroups; + event Func, Task>, CancellationToken, ValueTask> OnConnectionMapGroups; /// /// Gets a of current connection IDs for a given . From 337eb15a7fbc04492e9d47d3e02e3cc14efc24a0 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 12:12:15 -0500 Subject: [PATCH 60/72] User connection updates should bypass the jobs hub message rate limiter --- src/Tgstation.Server.Host/Jobs/JobService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tgstation.Server.Host/Jobs/JobService.cs b/src/Tgstation.Server.Host/Jobs/JobService.cs index 7793eb582b..41a68e56bf 100644 --- a/src/Tgstation.Server.Host/Jobs/JobService.cs +++ b/src/Tgstation.Server.Host/Jobs/JobService.cs @@ -341,7 +341,7 @@ namespace Tgstation.Server.Host.Jobs Stopwatch stopwatch = null; void QueueHubUpdate(JobResponse update, bool final) { - void NextUpdate() + void NextUpdate(bool bypassRate) { var currentUpdatesTask = hubUpdatesTask; async Task ChainHubUpdate() @@ -358,7 +358,7 @@ namespace Tgstation.Server.Host.Jobs Stopwatch enteredLock = null; try { - if (stopwatch != null) + if (!bypassRate && stopwatch != null) { Monitor.Enter(stopwatch); enteredLock = stopwatch; @@ -381,9 +381,9 @@ namespace Tgstation.Server.Host.Jobs if (final) hubUpdateActions.Remove(jobId); else - hubUpdateActions[jobId] = NextUpdate; + hubUpdateActions[jobId] = () => NextUpdate(true); - NextUpdate(); + NextUpdate(false); } try From 32d1f0ae5e4f5c508e5fc78f40a9f8c423d3267a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 13:13:01 -0500 Subject: [PATCH 61/72] Add FAQ link for DMAPI validation failures --- src/Tgstation.Server.Api/Models/ErrorCode.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index e10d4577c8..ae2dd3341c 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("DreamDaemon 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")] DreamMakerNeverValidated, /// From 399f19936af1ac43540882ee849d041e11d25f9e Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 16:35:45 -0500 Subject: [PATCH 62/72] Fix grammar in auth docs --- src/Tgstation.Server.Host/Security/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index 104db33ea4..852af14d87 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -16,7 +16,7 @@ ## 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. 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. From 3032955d2519fc54ab51aaeae8f618b463218c1d Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 16:41:42 -0500 Subject: [PATCH 63/72] Make this indentation make sense Also add a missing colon --- src/Tgstation.Server.Host/Security/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index 852af14d87..c12dd0f587 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -24,7 +24,8 @@ 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. - - If the user is using a username/password combo + 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. From f71ac09722f7c46f427044bfd32367bbc3e29f8c Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 16:42:33 -0500 Subject: [PATCH 64/72] Fix incorrect past tense --- src/Tgstation.Server.Host/Security/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Security/README.md b/src/Tgstation.Server.Host/Security/README.md index c12dd0f587..404b27e162 100644 --- a/src/Tgstation.Server.Host/Security/README.md +++ b/src/Tgstation.Server.Host/Security/README.md @@ -39,7 +39,7 @@ 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 deprecated the previous hashing method since the user last logged in. + - 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. From b939d722c9ece1aff739be2f9e0f86f45c1fdcb9 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Sun, 5 Nov 2023 17:26:53 -0500 Subject: [PATCH 65/72] Bump webpanel version to 4.26.0 --- build/ControlPanelVersion.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/ControlPanelVersion.props b/build/ControlPanelVersion.props index c2a4c37602..f6f147afdc 100644 --- a/build/ControlPanelVersion.props +++ b/build/ControlPanelVersion.props @@ -1,6 +1,6 @@ - 4.25.5 + 4.26.0 From 52fc12b7a4775e50ce9447c96cc6844c4df58171 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 16:19:24 -0500 Subject: [PATCH 66/72] Fix `JobsHubGroupMapper` not being initialized --- src/Tgstation.Server.Host/Core/Application.cs | 4 +++- src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs | 9 ++++++++- .../Utils/SignalR/ComprehensiveHubContext.cs | 5 +++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index a331feeb9f..a1cdabec96 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -404,7 +404,9 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(x => x.GetRequiredService()); - services.AddSingleton(); + 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()); diff --git a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs index 77ccf99b0b..719138e6ad 100644 --- a/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs +++ b/src/Tgstation.Server.Host/Jobs/JobsHubGroupMapper.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Tgstation.Server.Api.Hubs; @@ -19,7 +20,7 @@ namespace Tgstation.Server.Host.Jobs /// /// Handles mapping groups for the . /// - sealed class JobsHubGroupMapper : IPermissionsUpdateNotifyee + sealed class JobsHubGroupMapper : IPermissionsUpdateNotifyee, IHostedService { /// /// The for the . @@ -96,6 +97,12 @@ namespace Tgstation.Server.Host.Jobs cancellationToken); } + /// + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// /// Implementation of . /// diff --git a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs index 7999908052..3892aad56c 100644 --- a/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs +++ b/src/Tgstation.Server.Host/Utils/SignalR/ComprehensiveHubContext.cs @@ -83,12 +83,13 @@ namespace Tgstation.Server.Host.Utils.SignalR userId, context.ConnectionId); - var mappingTask = OnConnectionMapGroups( + var mappingTask = OnConnectionMapGroups?.Invoke( authenticationContext, mappedGroups => Task.WhenAll( mappedGroups.Select( group => hub.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken))), - cancellationToken); + cancellationToken) + ?? ValueTask.CompletedTask; userConnections.AddOrUpdate( userId, _ => new Dictionary From 9bea7686b28b455a69b29f1592300773e498e5e3 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 16:39:23 -0500 Subject: [PATCH 67/72] Add automatic reconnect to client hubs --- build/Version.props | 2 +- src/Tgstation.Server.Client/ApiClient.cs | 4 +- .../ApiClientTokenRefreshRetryPolicy.cs | 54 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs diff --git a/build/Version.props b/build/Version.props index f63dc186b2..99f5a049ee 100644 --- a/build/Version.props +++ b/build/Version.props @@ -8,7 +8,7 @@ 9.13.0 7.0.0 12.0.0 - 14.0.0 + 14.1.0 6.6.2 5.6.2 1.4.0 diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index ed48bc7bc7..f4a328d285 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -372,13 +372,15 @@ namespace Tgstation.Server.Client retryPolicy ??= new InfiniteThirtySecondMaxRetryPolicy(); + var wrappedPolicy = new ApiClientTokenRefreshRetryPolicy(this, retryPolicy); + HubConnection? hubConnection = null; var hubConnectionBuilder = new HubConnectionBuilder() .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings = SerializerSettings; }) - .WithAutomaticReconnect(retryPolicy) + .WithAutomaticReconnect(wrappedPolicy) .WithUrl( new Uri(Url, Routes.JobsHub), HttpTransportType.ServerSentEvents, diff --git a/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs new file mode 100644 index 0000000000..3ec5c1f6a1 --- /dev/null +++ b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs @@ -0,0 +1,54 @@ +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() + { + await apiClient.RefreshToken(CancellationToken.None); + } + } +} From abd1948d42efcd967ce6bc921735bf5a64aeb6fd Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 16:39:51 -0500 Subject: [PATCH 68/72] Version bump to 5.17.1 --- build/Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Version.props b/build/Version.props index 99f5a049ee..2ca5699ebf 100644 --- a/build/Version.props +++ b/build/Version.props @@ -3,7 +3,7 @@ - 5.17.0 + 5.17.1 4.7.1 9.13.0 7.0.0 From 37adb228d03a1e38399f11688898409cbba7f06a Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 16:56:30 -0500 Subject: [PATCH 69/72] Bump webpanel version to latest --- build/ControlPanelVersion.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/ControlPanelVersion.props b/build/ControlPanelVersion.props index f6f147afdc..02d75e7142 100644 --- a/build/ControlPanelVersion.props +++ b/build/ControlPanelVersion.props @@ -1,6 +1,6 @@ - 4.26.0 + 4.26.2 From 549b65f03c15e16ca3102b58cff3ef4ed4eef497 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 16:57:24 -0500 Subject: [PATCH 70/72] Update nuget packages for API library --- build/Version.props | 2 +- src/Tgstation.Server.Api/Tgstation.Server.Api.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build/Version.props b/build/Version.props index 2ca5699ebf..ae1f43929d 100644 --- a/build/Version.props +++ b/build/Version.props @@ -7,7 +7,7 @@ 4.7.1 9.13.0 7.0.0 - 12.0.0 + 12.0.1 14.1.0 6.6.2 5.6.2 diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index 3573822c52..e499846add 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -27,7 +27,7 @@ - + From 72388f8035b3abc9f84eec1c1cdc9e6cc9840a35 Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 17:44:45 -0500 Subject: [PATCH 71/72] Add necessary `ObjectDisposedException` to client --- src/Tgstation.Server.Client/ApiClient.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs index f4a328d285..bf0934a48b 100644 --- a/src/Tgstation.Server.Client/ApiClient.cs +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -482,6 +482,9 @@ 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 = SerializerSettings; From e68dc5b307d0436c917e0fbcd7d1dfe493fba1cd Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Mon, 6 Nov 2023 17:46:05 -0500 Subject: [PATCH 72/72] An uncaught exception was never a good idea --- .../ApiClientTokenRefreshRetryPolicy.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs index 3ec5c1f6a1..34556c24ec 100644 --- a/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs +++ b/src/Tgstation.Server.Client/ApiClientTokenRefreshRetryPolicy.cs @@ -48,7 +48,14 @@ namespace Tgstation.Server.Client /// async void AttemptTokenRefresh() { - await apiClient.RefreshToken(CancellationToken.None); + try + { + await apiClient.RefreshToken(CancellationToken.None); + } + catch + { + // intentionally ignored + } } } }