From d85e2b8d36ede54d8916d92c5911eb81e377173f Mon Sep 17 00:00:00 2001 From: Jordan Dominion Date: Tue, 31 Oct 2023 23:11:23 -0400 Subject: [PATCH] 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 @@ +