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 @@
+