Merge branch 'dev' into V6

This commit is contained in:
Jordan Dominion
2023-11-06 17:37:48 -05:00
141 changed files with 7766 additions and 975 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!-- This is in it's own file to help incremental building, changing it causes a complete rebuild of the web panel -->
<TgsControlPanelVersion>4.25.5</TgsControlPanelVersion>
<TgsControlPanelVersion>4.26.0</TgsControlPanelVersion>
</PropertyGroup>
</Project>
+2 -2
View File
@@ -15,8 +15,8 @@
<!-- Usage: Hard to say what exactly this is for, but not including it removes the test icon and breaks vstest.console.exe for some reason -->
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<!-- Usage: Dependency mocking for tests -->
<!-- Pinned: Moq is OVER https://github.com/moq/moq/issues/1372 -->
<PackageReference Include="Moq" Version="4.20.2" />
<!-- Pinned: Be VERY careful about updating https://github.com/moq/moq/issues/1372 -->
<PackageReference Include="Moq" Version="4.20.69" />
<!-- Usage: MSTest execution -->
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
<!-- Usage: MSTest asserts etc... -->
+3 -3
View File
@@ -7,9 +7,9 @@
<TgsConfigVersion>5.0.0</TgsConfigVersion>
<TgsApiVersion>10.0.0</TgsApiVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>12.0.0</TgsApiLibraryVersion>
<TgsClientVersion>14.0.0</TgsClientVersion>
<TgsDmapiVersion>6.6.1</TgsDmapiVersion>
<TgsApiLibraryVersion>13.0.0</TgsApiLibraryVersion>
<TgsClientVersion>16.0.0</TgsClientVersion>
<TgsDmapiVersion>6.6.2</TgsDmapiVersion>
<TgsInteropVersion>5.6.2</TgsInteropVersion>
<TgsHostWatchdogVersion>1.4.0</TgsHostWatchdogVersion>
<TgsContainerScriptVersion>1.2.1</TgsContainerScriptVersion>
+3 -3
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<RuleSet Name="myrules" Description="My rule set" ToolsVersion="17.0">
<Rules AnalyzerId="AsyncUsageAnalyzers" RuleNamespace="AsyncUsageAnalyzers">
<Rule Id="UseConfigureAwait" Action="Warning" />
@@ -88,7 +88,7 @@
<Rule Id="CA1414" Action="Warning" />
<Rule Id="CA1415" Action="Warning" />
<Rule Id="CA1500" Action="Warning" />
<Rule Id="CA1501" Action="Warning" />
<Rule Id="CA1501" Action="None" />
<Rule Id="CA1502" Action="Warning" />
<Rule Id="CA1504" Action="Warning" />
<Rule Id="CA1505" Action="Warning" />
@@ -971,7 +971,7 @@
<Rule Id="CA1033" Action="Warning" />
<Rule Id="CA1050" Action="Warning" />
<Rule Id="CA1060" Action="Warning" />
<Rule Id="CA1501" Action="Warning" />
<Rule Id="CA1501" Action="None" />
<Rule Id="CA1502" Action="Warning" />
<Rule Id="CA1505" Action="Warning" />
<Rule Id="CA1506" Action="Warning" />
+1 -1
View File
@@ -1,6 +1,6 @@
// tgstation-server DMAPI
#define TGS_DMAPI_VERSION "6.6.1"
#define TGS_DMAPI_VERSION "6.6.2"
// All functions and datums outside this document are subject to change with any version and should not be relied on.
+7 -2
View File
@@ -12,8 +12,13 @@ TGS_DEFINE_AND_SET_GLOBAL(tgs, null)
src.version = version
/datum/tgs_api/proc/TerminateWorld()
del(world)
sleep(1) // https://www.byond.com/forum/post/2894866
while(TRUE)
TGS_DEBUG_LOG("About to terminate world. Tick: [world.time], sleep_offline: [world.sleep_offline]")
world.sleep_offline = FALSE // https://www.byond.com/forum/post/2894866
del(world)
world.sleep_offline = FALSE // just in case, this is BYOND after all...
sleep(1)
TGS_DEBUG_LOG("BYOND DIDN'T TERMINATE THE WORLD!!! TICK IS: [world.time], sleep_offline: [world.sleep_offline]")
/datum/tgs_api/latest
parent_type = /datum/tgs_api/v5
+109 -32
View File
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Properties;
using Tgstation.Server.Common.Extensions;
@@ -57,6 +58,11 @@ namespace Tgstation.Server.Api
/// </summary>
public const string ApplicationJsonMime = "application/json";
/// <summary>
/// Added to <see cref="MediaTypeNames.Application"/> in netstandard2.1. Can't use because of lack of .NET Framework support.
/// </summary>
const string TextEventStreamMime = "text/event-stream";
/// <summary>
/// Get the version of the <see cref="Api"/> the caller is using.
/// </summary>
@@ -93,9 +99,9 @@ namespace Tgstation.Server.Api
public Version ApiVersion { get; }
/// <summary>
/// The client's JWT.
/// The client's <see cref="TokenResponse"/>.
/// </summary>
public string? Token { get; }
public TokenResponse? Token { get; }
/// <summary>
/// The client's username.
@@ -107,13 +113,18 @@ namespace Tgstation.Server.Api
/// </summary>
public string? Password { get; }
/// <summary>
/// The OAuth code in use.
/// </summary>
public string? OAuthCode { get; }
/// <summary>
/// The <see cref="Models.OAuthProvider"/> the <see cref="Token"/> is for, if any.
/// </summary>
public OAuthProvider? OAuthProvider { get; }
/// <summary>
/// If the header uses password or TGS JWT authentication.
/// If the header uses OAuth or TGS JWT authentication.
/// </summary>
public bool IsTokenAuthentication => Token != null && !OAuthProvider.HasValue;
@@ -129,16 +140,31 @@ namespace Tgstation.Server.Api
/// </summary>
/// <param name="userAgent">The value of <see cref="UserAgent"/>.</param>
/// <param name="token">The value of <see cref="Token"/>.</param>
/// <param name="oauthProvider">The value of <see cref="OAuthProvider"/>.</param>
public ApiHeaders(ProductHeaderValue userAgent, string token, OAuthProvider? oauthProvider = null)
public ApiHeaders(ProductHeaderValue userAgent, TokenResponse token)
: this(userAgent, token, null, null)
{
if (userAgent == null)
throw new ArgumentNullException(nameof(userAgent));
if (token == null)
throw new ArgumentNullException(nameof(token));
if (token.Bearer == null)
throw new InvalidOperationException("token.Bearer must be set!");
}
OAuthProvider = oauthProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ApiHeaders"/> class. Used for token authentication.
/// </summary>
/// <param name="userAgent">The value of <see cref="UserAgent"/>.</param>
/// <param name="oAuthCode">The value of <see cref="OAuthCode"/>.</param>
/// <param name="oAuthProvider">The value of <see cref="OAuthProvider"/>.</param>
public ApiHeaders(ProductHeaderValue userAgent, string oAuthCode, OAuthProvider oAuthProvider)
: this(userAgent, null, null, null)
{
if (userAgent == null)
throw new ArgumentNullException(nameof(userAgent));
OAuthCode = oAuthCode ?? throw new ArgumentNullException(nameof(oAuthCode));
OAuthProvider = oAuthProvider;
}
/// <summary>
@@ -163,48 +189,57 @@ namespace Tgstation.Server.Api
/// </summary>
/// <param name="requestHeaders">The <see cref="RequestHeaders"/> containing the serialized <see cref="ApiHeaders"/>.</param>
/// <param name="ignoreMissingAuth">If a missing <see cref="HeaderNames.Authorization"/> should be ignored.</param>
/// <param name="allowEventStreamAccept">If <see cref="TextEventStreamMime"/> is a valid accept.</param>
/// <exception cref="HeadersException">Thrown if the <paramref name="requestHeaders"/> constitue invalid <see cref="ApiHeaders"/>.</exception>
#pragma warning disable CA1502 // TODO: Decomplexify
public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth = false)
public ApiHeaders(RequestHeaders requestHeaders, bool ignoreMissingAuth, bool allowEventStreamAccept)
{
if (requestHeaders == null)
throw new ArgumentNullException(nameof(requestHeaders));
var badHeaders = HeaderTypes.None;
var badHeaders = HeaderErrorTypes.None;
var errorBuilder = new StringBuilder();
void AddError(HeaderTypes headerType, string message)
var multipleErrors = false;
void AddError(HeaderErrorTypes headerType, string message)
{
if (badHeaders != HeaderTypes.None)
if (badHeaders != HeaderErrorTypes.None)
{
multipleErrors = true;
errorBuilder.AppendLine();
}
badHeaders |= headerType;
errorBuilder.Append(message);
}
var jsonAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(ApplicationJsonMime);
if (!requestHeaders.Accept.Any(x => jsonAccept.IsSubsetOf(x)))
AddError(HeaderTypes.Accept, $"Client does not accept {ApplicationJsonMime}!");
var eventStreamAccept = new Microsoft.Net.Http.Headers.MediaTypeHeaderValue(TextEventStreamMime);
if (!requestHeaders.Accept.Any(jsonAccept.IsSubsetOf))
if (!allowEventStreamAccept)
AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime}!");
else if (!requestHeaders.Accept.Any(eventStreamAccept.IsSubsetOf))
AddError(HeaderErrorTypes.Accept, $"Client does not accept {ApplicationJsonMime} or {TextEventStreamMime}!");
if (!requestHeaders.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgentValues) || userAgentValues.Count == 0)
AddError(HeaderTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!");
AddError(HeaderErrorTypes.UserAgent, $"Missing {HeaderNames.UserAgent} header!");
else
{
RawUserAgent = userAgentValues.First();
if (String.IsNullOrWhiteSpace(RawUserAgent))
AddError(HeaderTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!");
AddError(HeaderErrorTypes.UserAgent, $"Malformed {HeaderNames.UserAgent} header!");
}
// make sure the api header matches ours
Version? apiVersion = null;
if (!requestHeaders.Headers.TryGetValue(ApiVersionHeader, out var apiUserAgentHeaderValues) || !ProductInfoHeaderValue.TryParse(apiUserAgentHeaderValues.FirstOrDefault(), out var apiUserAgent) || apiUserAgent.Product.Name != AssemblyName.Name)
AddError(HeaderTypes.Api, $"Missing {ApiVersionHeader} header!");
AddError(HeaderErrorTypes.Api, $"Missing {ApiVersionHeader} header!");
else if (!Version.TryParse(apiUserAgent.Product.Version, out apiVersion))
AddError(HeaderTypes.Api, $"Malformed {ApiVersionHeader} header!");
AddError(HeaderErrorTypes.Api, $"Malformed {ApiVersionHeader} header!");
if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization))
{
if (!ignoreMissingAuth)
AddError(HeaderTypes.Authorization, $"Missing {HeaderNames.Authorization} header!");
AddError(HeaderErrorTypes.AuthorizationMissing, $"Missing {HeaderNames.Authorization} header!");
}
else
{
@@ -212,13 +247,13 @@ namespace Tgstation.Server.Api
var splits = new List<string>(auth.Split(' '));
var scheme = splits.First();
if (String.IsNullOrWhiteSpace(scheme))
AddError(HeaderTypes.Authorization, "Missing authentication scheme!");
AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication scheme!");
else
{
splits.RemoveAt(0);
var parameter = String.Concat(splits);
if (String.IsNullOrEmpty(parameter))
AddError(HeaderTypes.Authorization, "Missing authentication parameter!");
AddError(HeaderErrorTypes.AuthorizationInvalid, "Missing authentication parameter!");
else
{
if (requestHeaders.Headers.TryGetValue(InstanceIdHeader, out var instanceIdValues))
@@ -237,14 +272,30 @@ namespace Tgstation.Server.Api
if (Enum.TryParse<OAuthProvider>(oauthProviderString, out var oauthProvider))
OAuthProvider = oauthProvider;
else
AddError(HeaderTypes.OAuthProvider, "Invalid OAuth provider!");
AddError(HeaderErrorTypes.OAuthProvider, "Invalid OAuth provider!");
}
else
AddError(HeaderTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!");
AddError(HeaderErrorTypes.OAuthProvider, $"Missing {OAuthProviderHeader} header!");
goto case BearerAuthenticationScheme;
OAuthCode = parameter;
break;
case BearerAuthenticationScheme:
Token = parameter;
Token = new TokenResponse
{
Bearer = parameter,
};
try
{
#pragma warning disable CS0618 // Type or member is obsolete
Token.ExpiresAt = Token.ParseJwt().ValidTo;
#pragma warning restore CS0618 // Type or member is obsolete
}
catch (ArgumentException ex) when (ex is not ArgumentNullException)
{
AddError(HeaderErrorTypes.AuthorizationInvalid, $"Invalid JWT: {ex.Message}");
}
break;
case BasicAuthenticationScheme:
string badBasicAuthHeaderMessage = $"Invalid basic {HeaderNames.Authorization} header!";
@@ -256,14 +307,14 @@ namespace Tgstation.Server.Api
}
catch
{
AddError(HeaderTypes.Authorization, badBasicAuthHeaderMessage);
AddError(HeaderErrorTypes.AuthorizationInvalid, badBasicAuthHeaderMessage);
break;
}
var basicAuthSplits = joinedString.Split(ColonSeparator, StringSplitOptions.RemoveEmptyEntries);
if (basicAuthSplits.Length < 2)
{
AddError(HeaderTypes.Authorization, badBasicAuthHeaderMessage);
AddError(HeaderErrorTypes.AuthorizationInvalid, badBasicAuthHeaderMessage);
break;
}
@@ -271,15 +322,20 @@ namespace Tgstation.Server.Api
Password = String.Concat(basicAuthSplits.Skip(1));
break;
default:
AddError(HeaderTypes.Authorization, "Invalid authentication scheme!");
AddError(HeaderErrorTypes.AuthorizationInvalid, "Invalid authentication scheme!");
break;
}
}
}
}
if (badHeaders != HeaderTypes.None)
if (badHeaders != HeaderErrorTypes.None)
{
if (multipleErrors)
errorBuilder.Insert(0, $"Multiple header validation errors occurred:{Environment.NewLine}");
throw new HeadersException(badHeaders, errorBuilder.ToString());
}
ApiVersion = apiVersion!.Semver();
}
@@ -292,7 +348,7 @@ namespace Tgstation.Server.Api
/// <param name="token">The value of <see cref="Token"/>.</param>
/// <param name="username">The value of <see cref="Username"/>.</param>
/// <param name="password">The value of <see cref="Password"/>.</param>
ApiHeaders(ProductHeaderValue userAgent, string? token, string? username, string? password)
ApiHeaders(ProductHeaderValue userAgent, TokenResponse? token, string? username, string? password)
{
RawUserAgent = userAgent?.ToString();
Token = token;
@@ -322,10 +378,10 @@ namespace Tgstation.Server.Api
headers.Clear();
headers.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJsonMime));
headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent));
headers.Add(ApiVersionHeader, new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString());
headers.Add(ApiVersionHeader, CreateApiVersionHeader());
if (OAuthProvider.HasValue)
{
headers.Authorization = new AuthenticationHeaderValue(OAuthAuthenticationScheme, Token);
headers.Authorization = new AuthenticationHeaderValue(OAuthAuthenticationScheme, OAuthCode!);
headers.Add(OAuthProviderHeader, OAuthProvider.ToString());
}
else if (!IsTokenAuthentication)
@@ -333,11 +389,32 @@ namespace Tgstation.Server.Api
BasicAuthenticationScheme,
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}")));
else
headers.Authorization = new AuthenticationHeaderValue(BearerAuthenticationScheme, Token);
headers.Authorization = new AuthenticationHeaderValue(BearerAuthenticationScheme, Token!.Bearer);
instanceId ??= InstanceId;
if (instanceId.HasValue)
headers.Add(InstanceIdHeader, instanceId.Value.ToString(CultureInfo.InvariantCulture));
}
/// <summary>
/// Adds the <paramref name="headers"/> necessary for a SignalR hub connection.
/// </summary>
/// <param name="headers">The headers <see cref="IDictionary{TKey, TValue}"/> to write to.</param>
public void SetHubConnectionHeaders(IDictionary<string, string> 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());
}
/// <summary>
/// Create the <see cref="string"/>ified for of the <see cref="ApiVersionHeader"/>.
/// </summary>
/// <returns>A <see cref="string"/> representing the <see cref="ApiVersion"/>.</returns>
string CreateApiVersionHeader()
=> new ProductHeaderValue(AssemblyName.Name, ApiVersion.ToString()).ToString();
}
}
@@ -0,0 +1,46 @@
using System;
namespace Tgstation.Server.Api
{
/// <summary>
/// Types of individual <see cref="ApiHeaders"/> errors.
/// </summary>
[Flags]
public enum HeaderErrorTypes
{
/// <summary>
/// No header errors.
/// </summary>
None = 0,
/// <summary>
/// The <see cref="Microsoft.Net.Http.Headers.HeaderNames.UserAgent"/> header is missing or invalid.
/// </summary>
UserAgent = 1 << 0,
/// <summary>
/// The <see cref="Microsoft.Net.Http.Headers.HeaderNames.Accept"/> header is missing or invalid.
/// </summary>
Accept = 1 << 1,
/// <summary>
/// The <see cref="ApiHeaders.ApiVersionHeader"/> header is missing or invalid.
/// </summary>
Api = 1 << 2,
/// <summary>
/// The <see cref="Microsoft.Net.Http.Headers.HeaderNames.Authorization"/> header is invalid.
/// </summary>
AuthorizationInvalid = 1 << 3,
/// <summary>
/// The <see cref="ApiHeaders.OAuthProviderHeader"/> header is missing or invalid.
/// </summary>
OAuthProvider = 1 << 4,
/// <summary>
/// The <see cref="Microsoft.Net.Http.Headers.HeaderNames.Authorization"/> header is missing.
/// </summary>
AuthorizationMissing = 1 << 5,
}
}
-41
View File
@@ -1,41 +0,0 @@
using System;
namespace Tgstation.Server.Api
{
/// <summary>
/// Types of individual <see cref="ApiHeaders"/>.
/// </summary>
[Flags]
public enum HeaderTypes
{
/// <summary>
/// No headers.
/// </summary>
None = 0,
/// <summary>
/// <see cref="Microsoft.Net.Http.Headers.HeaderNames.UserAgent"/> header.
/// </summary>
UserAgent = 1 << 0,
/// <summary>
/// <see cref="Microsoft.Net.Http.Headers.HeaderNames.Accept"/> header.
/// </summary>
Accept = 1 << 1,
/// <summary>
/// <see cref="ApiHeaders.ApiVersionHeader"/>.
/// </summary>
Api = 1 << 2,
/// <summary>
/// <see cref="Microsoft.Net.Http.Headers.HeaderNames.Authorization"/>
/// </summary>
Authorization = 1 << 3,
/// <summary>
/// <see cref="ApiHeaders.OAuthProviderHeader"/>.
/// </summary>
OAuthProvider = 1 << 4,
}
}
+5 -5
View File
@@ -8,19 +8,19 @@ namespace Tgstation.Server.Api
public sealed class HeadersException : Exception
{
/// <summary>
/// The <see cref="HeaderTypes"/>s that are missing or malformed.
/// The <see cref="HeaderErrorTypes"/>s that are missing or malformed.
/// </summary>
public HeaderTypes MissingOrMalformedHeaders { get; }
public HeaderErrorTypes ParseErrors { get; }
/// <summary>
/// Initializes a new instance of the <see cref="HeadersException"/> class.
/// </summary>
/// <param name="missingOrMalformedHeaders">The value of <see cref="MissingOrMalformedHeaders"/>.</param>
/// <param name="parseErrors">The value of <see cref="ParseErrors"/>.</param>
/// <param name="message">The error message.</param>
public HeadersException(HeaderTypes missingOrMalformedHeaders, string message)
public HeadersException(HeaderErrorTypes parseErrors, string message)
: base(message)
{
MissingOrMalformedHeaders = missingOrMalformedHeaders;
ParseErrors = parseErrors;
}
/// <summary>
+21
View File
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Api.Hubs
{
/// <summary>
/// SignalR client methods for receiving <see cref="JobResponse"/>s.
/// </summary>
public interface IJobsHub
{
/// <summary>
/// Push a <paramref name="job"/> update to the client.
/// </summary>
/// <param name="job">The <see cref="JobResponse"/> to push.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
Task ReceiveJobUpdate(JobResponse job, CancellationToken cancellationToken);
}
}
+1 -1
View File
@@ -326,7 +326,7 @@ namespace Tgstation.Server.Api.Models
/// <summary>
/// The DMAPI never validated itself
/// </summary>
[Description("DreamDaemon did not validate the DMAPI! This can occur if your world is encountering runtime errors during startup.")]
[Description("DMAPI validation failed! See FAQ at https://github.com/tgstation/tgstation-server/discussions/1695")]
DreamMakerNeverValidated,
/// <summary>
@@ -10,9 +10,16 @@ namespace Tgstation.Server.Api.Models.Internal
/// </summary>
public class Job : EntityId
{
/// <summary>
/// The <see cref="Models.JobCode"/>.
/// </summary>
[Required]
public JobCode? JobCode { get; set; }
/// <summary>
/// English description of the <see cref="Job"/>.
/// </summary>
/// <remarks>May not match the <see cref="System.ComponentModel.DescriptionAttribute"/> listed on the <see cref="Models.JobCode"/>.</remarks>
[Required]
public string? Description { get; set; }
+112
View File
@@ -0,0 +1,112 @@
using System.ComponentModel;
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// The different types of <see cref="Response.JobResponse"/>.
/// </summary>
public enum JobCode : byte
{
/// <summary>
/// This catch-all code is applied to jobs that were created on a tgstation-server before v5.17.0.
/// </summary>
[Description("Legacy job")]
Unknown,
/// <summary>
/// When the instance is being moved.
/// </summary>
[Description("Instance move")]
Move,
/// <summary>
/// When the repository is cloning.
/// </summary>
[Description("Clone repository")]
RepositoryClone,
/// <summary>
/// When the repository is being manually updated.
/// </summary>
[Description("Update repository")]
RepositoryUpdate,
/// <summary>
/// When the repository is being automatically updated.
/// </summary>
[Description("Scheduled repository update")]
RepositoryAutoUpdate,
/// <summary>
/// When the repository is being deleted.
/// </summary>
[Description("Delete repository")]
RepositoryDelete,
/// <summary>
/// When a new BYOND version is being installed.
/// </summary>
[Description("Install BYOND version")]
ByondOfficialInstall,
/// <summary>
/// When a new BYOND version is being installed.
/// </summary>
[Description("Install custom BYOND version")]
ByondCustomInstall,
/// <summary>
/// When an installed BYOND version is being deleted.
/// </summary>
[Description("Delete installed BYOND version")]
ByondDelete,
/// <summary>
/// When a deployment is manually triggered.
/// </summary>
[Description("Compile active repository code")]
Deployment,
/// <summary>
/// When a deployment is automatically triggered.
/// </summary>
[Description("Scheduled code deployment")]
AutomaticDeployment,
/// <summary>
/// When the watchdog is started manually.
/// </summary>
[Description("Launch DreamDaemon")]
WatchdogLaunch,
/// <summary>
/// When the watchdog is restarted manually.
/// </summary>
[Description("Restart Watchdog")]
WatchdogRestart,
/// <summary>
/// When a the watchdog is dumping the game server process.
/// </summary>
[Description("Create DreamDaemon Process Dump")]
WatchdogDump,
/// <summary>
/// When the watchdog starts due to an instance being onlined.
/// </summary>
[Description("Instance startup watchdog launch")]
StartupWatchdogLaunch,
/// <summary>
/// When the watchdog reattaches due to an instance being onlined.
/// </summary>
[Description("Instance startup watchdog reattach")]
StartupWatchdogReattach,
/// <summary>
/// When a chat bot connects/reconnects.
/// </summary>
[Description("Reconnect chat bot")]
ReconnectChatBot,
}
}
@@ -3,7 +3,6 @@
/// <summary>
/// For creating a user.
/// </summary>
#pragma warning disable CA1501
public sealed class UserCreateRequest : UserUpdateRequest
{
}
@@ -5,6 +5,11 @@
/// </summary>
public sealed class JobResponse : Internal.Job
{
/// <summary>
/// The <see cref="EntityId.Id"/> of the <see cref="Instance"/>.
/// </summary>
public long? InstanceId { get; set; }
/// <summary>
/// The <see cref="UserResponse"/> that started the job.
/// </summary>
@@ -1,5 +1,7 @@
using System;
using Microsoft.IdentityModel.JsonWebTokens;
namespace Tgstation.Server.Api.Models.Response
{
/// <summary>
@@ -15,6 +17,13 @@ namespace Tgstation.Server.Api.Models.Response
/// <summary>
/// When the <see cref="TokenResponse"/> expires.
/// </summary>
public DateTimeOffset ExpiresAt { get; set; }
[Obsolete("Will be removed in a future API version")]
public DateTimeOffset? ExpiresAt { get; set; }
/// <summary>
/// Parses the <see cref="Bearer"/> as a <see cref="JsonWebToken"/>.
/// </summary>
/// <returns>A new <see cref="JsonWebToken"/> based on <see cref="Bearer"/>.</returns>
public JsonWebToken ParseJwt() => new (Bearer);
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Tgstation.Server.Api.Rights
{
@@ -32,6 +33,15 @@ namespace Tgstation.Server.Api.Rights
/// <returns>The <see cref="Enum"/> <see cref="Type"/> of the given <paramref name="rightsType"/>.</returns>
public static Type RightToType(RightsType rightsType) => TypeMap[rightsType];
/// <summary>
/// Map a given <typeparamref name="TRight"/> to its respective <see cref="RightsType"/>.
/// </summary>
/// <typeparam name="TRight">The <see cref="Type"/> of the right.</typeparam>
/// <returns>The <see cref="RightsType"/> of <typeparamref name="TRight"/>.</returns>
public static RightsType TypeToRight<TRight>()
where TRight : Enum
=> TypeMap.First(kvp => kvp.Value == typeof(TRight)).Key;
/// <summary>
/// Gets the role claim name used for a given <paramref name="right"/>.
/// </summary>
+10
View File
@@ -12,6 +12,11 @@ namespace Tgstation.Server.Api
/// </summary>
public const string Root = "/";
/// <summary>
/// The root route of all hubs.
/// </summary>
public const string HubsRoot = Root + "hubs";
/// <summary>
/// The server administration controller.
/// </summary>
@@ -102,6 +107,11 @@ namespace Tgstation.Server.Api
/// </summary>
public const string List = "List";
/// <summary>
/// The root route of all hubs.
/// </summary>
public const string JobsHub = HubsRoot + "/jobs";
/// <summary>
/// Apply an <paramref name="id"/> postfix to a <paramref name="route"/>.
/// </summary>
@@ -26,6 +26,8 @@
<ItemGroup>
<!-- Usage: HTTP constants reference -->
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
<!-- Usage: Decoding the 'nbf' property of JWTs -->
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="7.0.2" />
<!-- Usage: Primary JSON library -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<!-- Usage: Data model annotating -->
+188 -44
View File
@@ -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;
}
/// <summary>
/// The <see cref="JsonSerializerSettings"/> to use.
/// </summary>
static readonly JsonSerializerSettings SerializerSettings = new ()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = new[]
{
new VersionConverter(),
},
};
/// <summary>
/// The <see cref="IHttpClient"/> for the <see cref="ApiClient"/>.
/// </summary>
@@ -61,6 +78,11 @@ namespace Tgstation.Server.Client
/// </summary>
readonly List<IRequestLogger> requestLoggers;
/// <summary>
/// List of <see cref="HubConnection"/>s created by the <see cref="ApiClient"/>.
/// </summary>
readonly List<HubConnection> hubConnections;
/// <summary>
/// Backing field for <see cref="Headers"/>.
/// </summary>
@@ -82,14 +104,9 @@ namespace Tgstation.Server.Client
ApiHeaders headers;
/// <summary>
/// Get the <see cref="JsonSerializerSettings"/> to use.
/// If the <see cref="ApiClient"/> is disposed.
/// </summary>
/// <returns>A new <see cref="JsonSerializerSettings"/> instance.</returns>
static JsonSerializerSettings GetSerializerSettings() => new ()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Converters = new[] { new VersionConverter() },
};
bool disposed;
/// <summary>
/// Handle a bad HTTP <paramref name="response"/>.
@@ -102,7 +119,7 @@ namespace Tgstation.Server.Client
try
{
// check if json serializes to an error message
errorMessage = JsonConvert.DeserializeObject<ErrorMessageResponse>(json, GetSerializerSettings());
errorMessage = JsonConvert.DeserializeObject<ErrorMessageResponse>(json, SerializerSettings);
}
catch (JsonException)
{
@@ -149,7 +166,12 @@ namespace Tgstation.Server.Client
/// <param name="apiHeaders">The value of <see cref="Headers"/>.</param>
/// <param name="tokenRefreshHeaders">The value of <see cref="tokenRefreshHeaders"/>.</param>
/// <param name="authless">The value of <see cref="authless"/>.</param>
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<IRequestLogger>();
hubConnections = new List<HubConnection>();
semaphoreSlim = new SemaphoreSlim(1);
}
/// <inheritdoc />
public void Dispose()
public async ValueTask DisposeAsync()
{
List<HubConnection> 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
}
}
/// <summary>
/// Attempt to refresh the stored Bearer token in <see cref="Headers"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="true"/> if the refresh was successful, <see langword="false"/> if a refresh is unable to be performed.</returns>
public async ValueTask<bool> 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<object, TokenResponse>(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken).ConfigureAwait(false);
headers = new ApiHeaders(headers.UserAgent!, token);
}
finally
{
semaphoreSlim.Release();
}
return true;
}
/// <inheritdoc />
public async ValueTask<IAsyncDisposable> CreateHubConnection<THubImplementation>(
THubImplementation hubImplementation,
IRetryPolicy? retryPolicy,
Action<ILoggingBuilder>? 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;
}
}
/// <summary>
/// Main request method.
/// </summary>
@@ -305,6 +464,7 @@ namespace Tgstation.Server.Client
/// <param name="tokenRefresh">If this is a token refresh operation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the response on success.</returns>
#pragma warning disable CA1506 // TODO: Decomplexify
protected virtual async ValueTask<TResult> RunRequest<TResult>(
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))
{
@@ -336,6 +496,21 @@ namespace Tgstation.Server.Client
if (authless)
request.Headers.Remove(HeaderNames.Authorization);
else
{
var bearer = headersToUse.Token?.Bearer;
if (bearer != null)
{
var parsed = headersToUse.Token!.ParseJwt();
var nbf = parsed.ValidFrom;
var now = DateTime.UtcNow;
if (nbf >= now)
{
var delay = (nbf - now).Add(TimeSpan.FromMilliseconds(1));
await Task.Delay(delay, cancellationToken);
}
}
}
if (fileDownload)
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Octet));
@@ -392,38 +567,7 @@ namespace Tgstation.Server.Client
}
}
}
/// <summary>
/// Attempt to refresh the bearer token in the <see cref="headers"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="true"/> if the refresh was successful, <see langword="false"/> otherwise.</returns>
async ValueTask<bool> 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<object, TokenResponse>(Routes.Root, new object(), HttpMethod.Post, null, true, cancellationToken).ConfigureAwait(false);
headers = new ApiHeaders(headers.UserAgent!, token.Bearer!);
}
catch (ClientException)
{
return false;
}
finally
{
semaphoreSlim.Release();
}
return true;
}
#pragma warning restore CA1506
/// <summary>
/// Main request method.
@@ -449,7 +593,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);
@@ -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
{
/// <summary>
/// Extension methods for the <see cref="HubConnection"/> <see langword="class"/>.
/// </summary>
static class HubConnectionExtensions
{
/// <summary>
/// Apply a given <paramref name="proxy"/> to a given <paramref name="hubConnection"/>.
/// </summary>
/// <typeparam name="TClientProxy">The strongly typed client proxy.</typeparam>
/// <param name="hubConnection">The <see cref="HubConnection"/> to proxy on.</param>
/// <param name="proxy">The <typeparamref name="TClientProxy"/> to forward operations to.</param>
public static void ProxyOn<TClientProxy>(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);
}
/// <summary>
/// Apply a given <paramref name="proxyObject"/> to a given <paramref name="hubConnection"/>.
/// </summary>
/// <param name="hubConnection">The <see cref="HubConnection"/> to proxy on.</param>
/// <param name="proxyType">The <see cref="Type"/> of <paramref name="proxyObject"/>.</param>
/// <param name="proxyObject">The <see cref="object"/> to forward operations to.</param>
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<object>.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);
}
}
}
+20 -1
View File
@@ -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
/// <summary>
/// Web interface for the API.
/// </summary>
interface IApiClient : IDisposable
interface IApiClient : IAsyncDisposable
{
/// <summary>
/// The <see cref="ApiHeaders"/> the <see cref="IApiClient"/> uses.
@@ -35,6 +38,22 @@ namespace Tgstation.Server.Client
/// <param name="requestLogger">The <see cref="IRequestLogger"/> to add.</param>
void AddRequestLogger(IRequestLogger requestLogger);
/// <summary>
/// Subscribe to all job updates available to the <see cref="IServerClient"/>.
/// </summary>
/// <typeparam name="THubImplementation">The <see cref="Type"/> of the hub being implemented.</typeparam>
/// <param name="hubImplementation">The <typeparamref name="THubImplementation"/> to use for proxying the methods of the hub connection.</param>
/// <param name="retryPolicy">The optional <see cref="IRetryPolicy"/> to use for the backing connection. The default retry policy waits for 1, 2, 4, 8, and 16 seconds, then 30s repeatedly.</param>
/// <param name="loggingConfigureAction">The optional <see cref="Action{T1}"/> used to configure a <see cref="ILoggingBuilder"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>An <see cref="IAsyncDisposable"/> representing the lifetime of the subscription.</returns>
ValueTask<IAsyncDisposable> CreateHubConnection<THubImplementation>(
THubImplementation hubImplementation,
IRetryPolicy? retryPolicy,
Action<ILoggingBuilder>? loggingConfigureAction,
CancellationToken cancellationToken)
where THubImplementation : class;
/// <summary>
/// Run an HTTP PUT request.
/// </summary>
@@ -17,6 +17,10 @@ namespace Tgstation.Server.Client
/// <param name="tokenRefreshHeaders">The <see cref="ApiHeaders"/> to use to generate a new <see cref="Api.Models.Response.TokenResponse"/>.</param>
/// <param name="authless">If there should be no authentication performed.</param>
/// <returns>A new <see cref="IApiClient"/>.</returns>
IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders, ApiHeaders? tokenRefreshHeaders, bool authless);
IApiClient CreateApiClient(
Uri url,
ApiHeaders apiHeaders,
ApiHeaders? tokenRefreshHeaders,
bool authless);
}
}
+19 -1
View File
@@ -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
/// <summary>
/// Main client for communicating with a server.
/// </summary>
public interface IServerClient : IDisposable
public interface IServerClient : IAsyncDisposable
{
/// <summary>
/// The connected server <see cref="Uri"/>.
@@ -53,6 +57,20 @@ namespace Tgstation.Server.Client
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="ServerInformationResponse"/> of the target server.</returns>
ValueTask<ServerInformationResponse> ServerInformation(CancellationToken cancellationToken);
/// <summary>
/// Subscribe to all job updates available to the <see cref="IServerClient"/>.
/// </summary>
/// <param name="jobsReceiver">The <see cref="IJobsHub"/> to use to subscribe to updates.</param>
/// <param name="retryPolicy">The optional <see cref="IRetryPolicy"/> to use for the backing connection. The default retry policy waits for 1, 2, 4, 8, and 16 seconds, then 30s repeatedly.</param>
/// <param name="loggingConfigureAction">The optional <see cref="Action{T1}"/> used to configure a <see cref="ILoggingBuilder"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>An <see cref="IAsyncDisposable"/> representing the lifetime of the subscription.</returns>
ValueTask<IAsyncDisposable> SubscribeToJobUpdates(
IJobsHub jobsReceiver,
IRetryPolicy? retryPolicy = null,
Action<ILoggingBuilder>? loggingConfigureAction = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a <paramref name="requestLogger"/> to the request pipeline.
/// </summary>
@@ -0,0 +1,21 @@
using System;
using Microsoft.AspNetCore.SignalR.Client;
namespace Tgstation.Server.Client
{
/// <summary>
/// A <see cref="IRetryPolicy"/> that returns seconds in powers of 2, maxing out at 30s.
/// </summary>
sealed class InfiniteThirtySecondMaxRetryPolicy : IRetryPolicy
{
/// <inheritdoc />
public TimeSpan? NextRetryDelay(RetryContext retryContext)
{
if (retryContext == null)
throw new ArgumentNullException(nameof(retryContext));
return TimeSpan.FromSeconds(Math.Min(Math.Pow(2, retryContext.PreviousRetryCount), 30));
}
}
}
+16 -18
View File
@@ -2,7 +2,11 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Hubs;
using Tgstation.Server.Api.Models.Response;
namespace Tgstation.Server.Client
@@ -16,12 +20,8 @@ namespace Tgstation.Server.Client
/// <inheritdoc />
public TokenResponse Token
{
get => token;
set
{
token = value ?? throw new InvalidOperationException("Cannot set a null Token!");
apiClient.Headers = new ApiHeaders(apiClient.Headers.UserAgent!, token.Bearer!);
}
get => apiClient.Headers.Token ?? throw new InvalidOperationException("apiClient.Headers.Token was null!");
set => apiClient.Headers = new ApiHeaders(apiClient.Headers.UserAgent!, value);
}
/// <inheritdoc />
@@ -48,23 +48,13 @@ namespace Tgstation.Server.Client
/// </summary>
readonly IApiClient apiClient;
/// <summary>
/// Backing field for <see cref="Token"/>.
/// </summary>
TokenResponse token;
/// <summary>
/// Initializes a new instance of the <see cref="ServerClient"/> class.
/// </summary>
/// <param name="apiClient">The value of <see cref="apiClient"/>.</param>
/// <param name="token">The value of <see cref="Token"/>.</param>
public ServerClient(IApiClient apiClient, TokenResponse token)
public ServerClient(IApiClient apiClient)
{
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
this.token = token ?? throw new ArgumentNullException(nameof(token));
if (Token.Bearer != apiClient.Headers.Token)
throw new ArgumentOutOfRangeException(nameof(token), token, "Provided token does not match apiClient headers!");
Instances = new InstanceManagerClient(apiClient);
Users = new UsersClient(apiClient);
@@ -73,12 +63,20 @@ namespace Tgstation.Server.Client
}
/// <inheritdoc />
public void Dispose() => apiClient.Dispose();
public ValueTask DisposeAsync() => apiClient.DisposeAsync();
/// <inheritdoc />
public ValueTask<ServerInformationResponse> ServerInformation(CancellationToken cancellationToken) => apiClient.Read<ServerInformationResponse>(Routes.Root, cancellationToken);
/// <inheritdoc />
public void AddRequestLogger(IRequestLogger requestLogger) => apiClient.AddRequestLogger(requestLogger);
/// <inheritdoc />
public ValueTask<IAsyncDisposable> SubscribeToJobUpdates(
IJobsHub jobsReceiver,
IRetryPolicy? retryPolicy,
Action<ILoggingBuilder>? loggingConfigureAction,
CancellationToken cancellationToken)
=> apiClient.CreateHubConnection(jobsReceiver, retryPolicy, loggingConfigureAction, cancellationToken);
}
}
@@ -102,7 +102,15 @@ namespace Tgstation.Server.Client
if (token.Bearer == null)
throw new InvalidOperationException("token.Bearer should not be null!");
return new ServerClient(ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer), null, false), token);
var serverClient = new ServerClient(
ApiClientFactory.CreateApiClient(
host,
new ApiHeaders(
productHeaderValue,
token),
null,
false));
return serverClient;
}
/// <inheritdoc />
@@ -112,7 +120,16 @@ namespace Tgstation.Server.Client
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
using var api = ApiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, "fake"), null, true);
await using var api = ApiClientFactory.CreateApiClient(
host,
new ApiHeaders(
productHeaderValue,
new TokenResponse
{
Bearer = "unused",
}),
null,
true);
if (requestLoggers != null)
foreach (var requestLogger in requestLoggers)
@@ -145,7 +162,7 @@ namespace Tgstation.Server.Client
requestLoggers ??= Enumerable.Empty<IRequestLogger>();
TokenResponse token;
using (var api = ApiClientFactory.CreateApiClient(host, loginHeaders, null, false))
await using (var api = ApiClientFactory.CreateApiClient(host, loginHeaders, null, false))
{
foreach (var requestLogger in requestLoggers)
api.AddRequestLogger(requestLogger);
@@ -155,14 +172,13 @@ namespace Tgstation.Server.Client
token = await api.Update<TokenResponse>(Routes.Root, cancellationToken).ConfigureAwait(false);
}
var apiHeaders = new ApiHeaders(productHeaderValue, token.Bearer!);
var apiHeaders = new ApiHeaders(productHeaderValue, token);
var client = new ServerClient(
ApiClientFactory.CreateApiClient(
host,
apiHeaders,
attemptLoginRefresh ? loginHeaders : null,
false),
token);
false));
if (timeout.HasValue)
client.Timeout = timeout.Value;
@@ -9,6 +9,13 @@
<PackageReleaseNotes>$(TGS_NUGET_RELEASE_NOTES_CLIENT)</PackageReleaseNotes>
</PropertyGroup>
<ItemGroup>
<!-- Usage: Connecting to SignalR hubs in API -->
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.0-rc.1.23421.29" />
<!-- Usage: Using target JSON serializer for API -->
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.0-rc.1.23421.29" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tgstation.Server.Api\Tgstation.Server.Api.csproj" />
<ProjectReference Include="..\Tgstation.Server.Common\Tgstation.Server.Common.csproj" /> <!-- Needed for explicit nuget versioning -->
@@ -372,7 +372,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
{
var completionString = errorMessage == null ? "Pending" : "Failed";
Embed CreateUpdatedEmbed(string message, Color color) => new Embed
Embed CreateUpdatedEmbed(string message, Color color) => new ()
{
Author = embed.Author,
Colour = color,
@@ -352,7 +352,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
dbChannel,
new List<ChannelRepresentation>
{
new ChannelRepresentation
new ()
{
RealId = id.Value,
IsAdminChannel = dbChannel.IsAdminChannel == true,
@@ -273,13 +273,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
connectNow = false;
if (!Connected)
{
var job = new Job
{
Description = $"Reconnect chat bot: {ChatBot.Name}",
CancelRight = (ulong)ChatBotRights.WriteEnabled,
CancelRightsType = RightsType.ChatBots,
Instance = ChatBot.Instance,
};
var job = Job.Create(Api.Models.JobCode.ReconnectChatBot, null, ChatBot.Instance, ChatBotRights.WriteEnabled);
job.Description += $": {ChatBot.Name}";
await jobManager.RegisterOperation(
job,
@@ -239,6 +239,8 @@ namespace Tgstation.Server.Host.Components.Deployment
.Where(x => x.Id == compileJob.Id)
.Include(x => x.Job)
.ThenInclude(x => x.StartedBy)
.Include(x => x.Job)
.ThenInclude(x => x.Instance)
.Include(x => x.RevisionInformation)
.ThenInclude(x => x.PrimaryTestMerge)
.ThenInclude(x => x.MergedBy)
@@ -337,10 +337,7 @@ namespace Tgstation.Server.Host.Components.Deployment
async databaseContext =>
{
var fullJob = compileJob.Job;
compileJob.Job = new Models.Job
{
Id = job.Id,
};
compileJob.Job = new Models.Job(job.Id.Value);
var fullRevInfo = compileJob.RevisionInformation;
compileJob.RevisionInformation = new Models.RevisionInformation
{
@@ -431,10 +428,10 @@ namespace Tgstation.Server.Host.Components.Deployment
.Where(x => x.Job.Instance.Id == metadata.Id)
.OrderByDescending(x => x.Job.StoppedAt)
.Take(10)
.Select(x => new Models.Job
.Select(x => new
{
StoppedAt = x.Job.StoppedAt,
StartedAt = x.Job.StartedAt,
x.Job.StoppedAt,
x.Job.StartedAt,
})
.ToListAsync(cancellationToken);
@@ -32,10 +32,7 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote
}
/// <inheritdoc />
public override ValueTask FailDeployment(Models.CompileJob compileJob, string errorMessage, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
public override ValueTask FailDeployment(Models.CompileJob compileJob, string errorMessage, CancellationToken cancellationToken) => ValueTask.CompletedTask;
/// <inheritdoc />
public override ValueTask<IReadOnlyCollection<TestMerge>> RemoveMergedTestMerges(IRepository repository, Models.RepositorySettings repositorySettings, Models.RevisionInformation revisionInformation, CancellationToken cancellationToken)
@@ -69,10 +66,7 @@ namespace Tgstation.Server.Host.Components.Deployment.Remote
=> String.Empty;
/// <inheritdoc />
protected override ValueTask MarkInactiveImpl(Models.CompileJob compileJob, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
protected override ValueTask MarkInactiveImpl(Models.CompileJob compileJob, CancellationToken cancellationToken) => ValueTask.CompletedTask;
/// <inheritdoc />
protected override ValueTask StageDeploymentImpl(Models.CompileJob compileJob, CancellationToken cancellationToken) => ValueTask.CompletedTask;
@@ -161,5 +161,11 @@
/// </summary>
[EventScript("DeploymentCleanup")]
DeploymentCleanup,
/// <summary>
/// Whenever a deployment is about to be used by the game server. May fire multiple times per deployment. Parameters: Game directory path
/// </summary>
[EventScript("DeploymentActivation")]
DeploymentActivation,
}
}
@@ -10,6 +10,6 @@ namespace Tgstation.Server.Host.Components
/// <summary>
/// A unique ID for the <see cref="IInstanceReference"/>.
/// </summary>
public Guid Uid { get; }
public ulong Uid { get; }
}
}
@@ -501,17 +501,7 @@ namespace Tgstation.Server.Host.Components
await eventConsumer.HandleEvent(EventType.InstanceAutoUpdateStart, Enumerable.Empty<string>(), true, cancellationToken);
try
{
var repositoryUpdateJob = new Job
{
Instance = new Models.Instance
{
Id = metadata.Id,
},
Description = "Scheduled repository update",
CancelRightsType = RightsType.Repository,
CancelRight = (ulong)RepositoryRights.CancelPendingChanges,
};
var repositoryUpdateJob = Job.Create(Api.Models.JobCode.RepositoryAutoUpdate, null, metadata, RepositoryRights.CancelPendingChanges);
await jobManager.RegisterOperation(
repositoryUpdateJob,
RepositoryAutoUpdateJob,
@@ -541,14 +531,7 @@ namespace Tgstation.Server.Host.Components
}
// finally set up the job
compileProcessJob = new Job
{
Instance = repositoryUpdateJob.Instance,
Description = "Scheduled code deployment",
CancelRightsType = RightsType.DreamMaker,
CancelRight = (ulong)DreamMakerRights.CancelCompile,
};
compileProcessJob = Job.Create(Api.Models.JobCode.AutomaticDeployment, null, metadata, DreamMakerRights.CancelCompile);
await jobManager.RegisterOperation(
compileProcessJob,
(core, databaseContextFactory, job, progressReporter, jobCancellationToken) =>
@@ -351,10 +351,7 @@ namespace Tgstation.Server.Host.Components
.Jobs
.AsQueryable()
.Where(x => x.Instance.Id == metadata.Id && !x.StoppedAt.HasValue)
.Select(x => new Models.Job
{
Id = x.Id,
})
.Select(x => new Models.Job(x.Id.Value))
.ToListAsync(cancellationToken);
foreach (var job in jobs)
tasks.Add(jobService.CancelJob(job, user, true, cancellationToken));
@@ -1,5 +1,4 @@
using System;
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Host.Components.Byond;
@@ -18,8 +17,13 @@ namespace Tgstation.Server.Host.Components
/// </summary>
sealed class InstanceWrapper : ReferenceCounter<IInstance>, IInstanceReference
{
/// <summary>
/// Static counter for <see cref="Uid"/>.
/// </summary>
static ulong instanceWrapperInstances;
/// <inheritdoc />
public Guid Uid { get; }
public ulong Uid { get; }
/// <inheritdoc />
public IRepositoryManager RepositoryManager => Instance.RepositoryManager;
@@ -44,7 +48,7 @@ namespace Tgstation.Server.Host.Components
/// </summary>
public InstanceWrapper()
{
Uid = Guid.NewGuid();
Uid = Interlocked.Increment(ref instanceWrapperInstances);
}
/// <inheritdoc />
@@ -689,10 +689,12 @@ namespace Tgstation.Server.Host.Components.Session
return;
}
Logger.LogDebug("Server will terminated in 10s if it does not exit...");
var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(10), CancellationToken.None); // DCT: None available
var completedTask = await Task.WhenAny(process.Lifetime, delayTask);
if (completedTask == delayTask)
const int GracePeriodSeconds = 30;
Logger.LogDebug("Server will terminated in {gracePeriodSeconds}s if it does not exit...", GracePeriodSeconds);
var delayTask = asyncDelayer.Delay(TimeSpan.FromSeconds(GracePeriodSeconds), CancellationToken.None); // DCT: None available
await Task.WhenAny(process.Lifetime, delayTask);
if (!process.Lifetime.IsCompleted)
{
Logger.LogWarning("DMAPI took too long to shutdown server after validation request!");
process.Terminate();
@@ -73,7 +73,7 @@ namespace Tgstation.Server.Host.Components.StaticFiles
/// </summary>
static readonly IReadOnlyDictionary<EventType, string> EventTypeScriptFileNameMap = new Dictionary<EventType, string>(
Enum.GetValues(typeof(EventType))
.OfType<EventType>()
.Cast<EventType>()
.Select(
eventType => new KeyValuePair<EventType, string>(
eventType,
@@ -29,11 +29,6 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
protected SwappableDmbProvider ActiveSwappable { get; private set; }
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="AdvancedWatchdog"/> pointing to the Game directory.
/// </summary>
protected IIOManager GameIOManager { get; }
/// <summary>
/// The <see cref="IFilesystemLinkFactory"/> for the <see cref="AdvancedWatchdog"/>.
/// </summary>
@@ -64,10 +59,10 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="jobManager">The <see cref="IJobManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="serverControl">The <see cref="IServerControl"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="asyncDelayer">The <see cref="IAsyncDelayer"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="diagnosticsIOManager">The <see cref="IIOManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="diagnosticsIOManager">The 'Diagnostics' <see cref="IIOManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="eventConsumer">The <see cref="IEventConsumer"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="remoteDeploymentManagerFactory">The <see cref="IRemoteDeploymentManagerFactory"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="gameIOManager">The value of <see cref="GameIOManager"/>.</param>
/// <param name="gameIOManager">The 'Game' <see cref="IIOManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="linkFactory">The value of <see cref="LinkFactory"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="initialLaunchParameters">The <see cref="DreamDaemonLaunchParameters"/> for the <see cref="WatchdogBase"/>.</param>
@@ -101,6 +96,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
diagnosticsIOManager,
eventConsumer,
remoteDeploymentManagerFactory,
gameIOManager,
logger,
initialLaunchParameters,
instance,
@@ -108,7 +104,6 @@ namespace Tgstation.Server.Host.Components.Watchdog
{
try
{
GameIOManager = gameIOManager ?? throw new ArgumentNullException(nameof(gameIOManager));
LinkFactory = linkFactory ?? throw new ArgumentNullException(nameof(linkFactory));
deploymentCleanupTasks = new List<Task>();
@@ -50,9 +50,10 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="jobManager">The <see cref="IJobManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="serverControl">The <see cref="IServerControl"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="asyncDelayer">The <see cref="IAsyncDelayer"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="diagnosticsIOManager">The <see cref="IIOManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="diagnosticsIOManager">The 'Diagnostics' <see cref="IIOManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="eventConsumer">The <see cref="IEventConsumer"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="remoteDeploymentManagerFactory">The <see cref="IRemoteDeploymentManagerFactory"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="gameIOManager">The 'Game' <see cref="IIOManager"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="initialLaunchParameters">The <see cref="DreamDaemonLaunchParameters"/> for the <see cref="WatchdogBase"/>.</param>
/// <param name="instance">The <see cref="Api.Models.Instance"/> for the <see cref="WatchdogBase"/>.</param>
@@ -68,6 +69,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
IIOManager diagnosticsIOManager,
IEventConsumer eventConsumer,
IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory,
IIOManager gameIOManager,
ILogger<BasicWatchdog> logger,
DreamDaemonLaunchParameters initialLaunchParameters,
Api.Models.Instance instance,
@@ -83,6 +85,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
diagnosticsIOManager,
eventConsumer,
remoteDeploymentManagerFactory,
gameIOManager,
logger,
initialLaunchParameters,
instance,
@@ -96,6 +96,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
protected IAsyncDelayer AsyncDelayer { get; }
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="WatchdogBase"/> pointing to the Game directory.
/// </summary>
protected IIOManager GameIOManager { get; }
/// <summary>
/// The <see cref="Api.Models.Instance"/> for the <see cref="WatchdogBase"/>.
/// </summary>
@@ -184,6 +189,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="diagnosticsIOManager">The value of <see cref="diagnosticsIOManager"/>.</param>
/// <param name="eventConsumer">The value of <see cref="EventConsumer"/>.</param>
/// <param name="remoteDeploymentManagerFactory">The value of <see cref="remoteDeploymentManagerFactory"/>.</param>
/// <param name="gameIOManager">The value of <see cref="GameIOManager"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
/// <param name="initialLaunchParameters">The initial value of <see cref="ActiveLaunchParameters"/>. May be modified.</param>
/// <param name="metadata">The value of <see cref="metadata"/>.</param>
@@ -199,6 +205,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
IIOManager diagnosticsIOManager,
IEventConsumer eventConsumer,
IRemoteDeploymentManagerFactory remoteDeploymentManagerFactory,
IIOManager gameIOManager,
ILogger<WatchdogBase> logger,
DreamDaemonLaunchParameters initialLaunchParameters,
Api.Models.Instance metadata,
@@ -213,6 +220,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
this.diagnosticsIOManager = diagnosticsIOManager ?? throw new ArgumentNullException(nameof(diagnosticsIOManager));
this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer));
this.remoteDeploymentManagerFactory = remoteDeploymentManagerFactory ?? throw new ArgumentNullException(nameof(remoteDeploymentManagerFactory));
GameIOManager = gameIOManager ?? throw new ArgumentNullException(nameof(gameIOManager));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
ActiveLaunchParameters = initialLaunchParameters ?? throw new ArgumentNullException(nameof(initialLaunchParameters));
this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
@@ -369,16 +377,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
if (!autoStart && !reattaching)
return;
var job = new Models.Job
{
Instance = new Models.Instance
{
Id = metadata.Id,
},
Description = $"Instance startup watchdog {(reattaching ? "reattach" : "launch")}",
CancelRight = (ulong)DreamDaemonRights.Shutdown,
CancelRightsType = RightsType.DreamDaemon,
};
var job = Models.Job.Create(
reattaching
? JobCode.StartupWatchdogReattach
: JobCode.StartupWatchdogLaunch,
null,
metadata,
DreamDaemonRights.Shutdown);
await jobManager.RegisterOperation(
job,
async (core, databaseContextFactory, paramJob, progressFunction, ct) =>
@@ -679,6 +684,15 @@ namespace Tgstation.Server.Host.Components.Watchdog
metadata,
newCompileJob);
var eventTask = eventConsumer.HandleEvent(
EventType.DeploymentActivation,
new List<string>
{
GameIOManager.ResolvePath(newCompileJob.DirectoryName.ToString()),
},
false,
cancellationToken);
try
{
await remoteDeploymentManager.ApplyDeployment(newCompileJob, cancellationToken);
@@ -687,6 +701,8 @@ namespace Tgstation.Server.Host.Components.Watchdog
{
Logger.LogWarning(ex, "Failed to apply remote deployment!");
}
await eventTask;
}
/// <summary>
@@ -90,6 +90,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
diagnosticsIOManager,
eventConsumer,
remoteDeploymentManagerfactory,
gameIOManager,
LoggerFactory.CreateLogger<BasicWatchdog>(),
settings,
instance,
@@ -57,16 +57,17 @@ namespace Tgstation.Server.Host.Configuration
ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
ArgumentNullException.ThrowIfNull(platformIdentifier);
var directoryToUse = platformIdentifier.IsWindows
? Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) // C:/ProgramData
: "/var/log"; // :pain:
if (!String.IsNullOrEmpty(Directory))
return Directory;
return !String.IsNullOrEmpty(Directory)
? Directory
: ioManager.ConcatPath(
directoryToUse,
return platformIdentifier.IsWindows
? ioManager.ConcatPath(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
assemblyInformationProvider.VersionPrefix,
"logs");
"logs")
: ioManager.ConcatPath(
"/var/log",
assemblyInformationProvider.VersionPrefix);
}
}
}
@@ -18,6 +18,7 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
@@ -25,6 +26,7 @@ using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils;
using Tgstation.Server.Host.Utils.GitHub;
namespace Tgstation.Server.Host.Controllers
@@ -84,7 +86,7 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="AdministrationController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="gitHubServiceFactory">The value of <see cref="gitHubServiceFactory"/>.</param>
/// <param name="serverControl">The value of <see cref="serverControl"/>.</param>
/// <param name="serverUpdateInitiator">The value of <see cref="serverUpdateInitiator"/>.</param>
@@ -94,9 +96,10 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="fileLoggingConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="fileLoggingConfiguration"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public AdministrationController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
IGitHubServiceFactory gitHubServiceFactory,
IServerControl serverControl,
IServerUpdateInitiator serverUpdateInitiator,
@@ -105,10 +108,12 @@ namespace Tgstation.Server.Host.Controllers
IPlatformIdentifier platformIdentifier,
IFileTransferTicketProvider fileTransferService,
ILogger<AdministrationController> logger,
IOptions<FileLoggingConfiguration> fileLoggingConfigurationOptions)
IOptions<FileLoggingConfiguration> fileLoggingConfigurationOptions,
IApiHeadersProvider apiHeadersProvider)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
apiHeadersProvider,
logger,
true)
{
@@ -3,13 +3,11 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Logging;
@@ -23,6 +21,7 @@ using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Common.Extensions;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
@@ -34,9 +33,7 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Base <see cref="Controller"/> for API functions.
/// </summary>
[Produces(MediaTypeNames.Application.Json)]
[ApiController]
public abstract class ApiController : Controller
public abstract class ApiController : ApiControllerBase
{
/// <summary>
/// Default size of <see cref="Paginated{TModel}"/> results.
@@ -51,7 +48,12 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// The <see cref="Api.ApiHeaders"/> for the operation.
/// </summary>
protected ApiHeaders ApiHeaders { get; private set; }
protected ApiHeaders ApiHeaders => ApiHeadersProvider.ApiHeaders;
/// <summary>
/// The <see cref="IApiHeadersProvider"/> containing value of <see cref="ApiHeaders"/>.
/// </summary>
protected IApiHeadersProvider ApiHeadersProvider { get; }
/// <summary>
/// The <see cref="IDatabaseContext"/> for the operation.
@@ -82,69 +84,43 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="ApiController"/> class.
/// </summary>
/// <param name="databaseContext">The value of <see cref="DatabaseContext"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
/// <param name="apiHeadersProvider">The value of <see cref="ApiHeadersProvider"/>..</param>
/// <param name="requireHeaders">The value of <see cref="requireHeaders"/>.</param>
protected ApiController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
IApiHeadersProvider apiHeadersProvider,
ILogger<ApiController> logger,
bool requireHeaders)
{
DatabaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext));
ArgumentNullException.ThrowIfNull(authenticationContextFactory);
AuthenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
ApiHeadersProvider = apiHeadersProvider ?? throw new ArgumentNullException(nameof(apiHeadersProvider));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
AuthenticationContext = authenticationContextFactory.CurrentAuthenticationContext;
Instance = AuthenticationContext?.InstancePermissionSet?.Instance;
this.requireHeaders = requireHeaders;
}
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
protected override async ValueTask<IActionResult> HookExecuteAction(Func<Task> executeAction, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
// ALL valid token and login requests that match a route go through this function
// 404 is returned before
if (AuthenticationContext != null && AuthenticationContext.User == null)
{
// valid token, expired password
await Unauthorized().ExecuteResultAsync(context);
return;
}
ArgumentNullException.ThrowIfNull(executeAction);
// validate the headers
try
{
ApiHeaders = new ApiHeaders(Request.GetTypedHeaders());
if (!ApiHeaders.Compatible())
{
await this.StatusCode(
HttpStatusCode.UpgradeRequired,
new ErrorMessageResponse(ErrorCode.ApiMismatch))
.ExecuteResultAsync(context);
return;
}
var errorCase = await ValidateRequest(context.HttpContext.RequestAborted);
if (errorCase != null)
{
await errorCase.ExecuteResultAsync(context);
return;
}
}
catch (HeadersException)
if (ApiHeaders == null)
{
if (requireHeaders)
{
await HeadersIssue(false)
.ExecuteResultAsync(context);
return;
}
return HeadersIssue(ApiHeadersProvider.HeadersException);
}
var errorCase = await ValidateRequest(cancellationToken);
if (errorCase != null)
return errorCase;
if (ModelState?.IsValid == false)
{
var errorMessages = ModelState
@@ -158,15 +134,11 @@ namespace Tgstation.Server.Host.Controllers
.Where(x => !x.EndsWith(" field is required.", StringComparison.Ordinal));
if (errorMessages.Any())
{
await BadRequest(
return BadRequest(
new ErrorMessageResponse(ErrorCode.ModelValidationFailure)
{
AdditionalData = String.Join(Environment.NewLine, errorMessages),
})
.ExecuteResultAsync(context);
return;
}
});
ModelState.Clear();
}
@@ -174,7 +146,7 @@ namespace Tgstation.Server.Host.Controllers
using (ApiHeaders?.InstanceId != null
? LogContext.PushProperty(SerilogContextHelper.InstanceIdContextProperty, ApiHeaders.InstanceId)
: null)
using (AuthenticationContext != null
using (AuthenticationContext.Valid
? LogContext.PushProperty(SerilogContextHelper.UserIdContextProperty, AuthenticationContext.User.Id)
: null)
using (LogContext.PushProperty(SerilogContextHelper.RequestPathContextProperty, $"{Request.Method} {Request.Path}"))
@@ -182,15 +154,14 @@ namespace Tgstation.Server.Host.Controllers
if (ApiHeaders != null)
{
var isGet = HttpMethods.IsGet(Request.Method);
if (!(isGet && Request.Path.StartsWithSegments(Routes.Jobs, StringComparison.OrdinalIgnoreCase)))
Logger.Log(
isGet
? LogLevel.Trace
: LogLevel.Debug,
"Starting API request: Version: {clientApiVersion}. {userAgentHeaderName}: {clientUserAgent}",
ApiHeaders.ApiVersion.Semver(),
HeaderNames.UserAgent,
ApiHeaders.RawUserAgent);
Logger.Log(
isGet
? LogLevel.Trace
: LogLevel.Debug,
"Starting API request: Version: {clientApiVersion}. {userAgentHeaderName}: {clientUserAgent}",
ApiHeaders.ApiVersion.Semver(),
HeaderNames.UserAgent,
ApiHeaders.RawUserAgent);
}
else if (Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgents))
Logger.LogDebug(
@@ -201,17 +172,26 @@ namespace Tgstation.Server.Host.Controllers
Logger.LogDebug(
"Starting unauthorized API request. No {userAgentHeaderName}!",
HeaderNames.UserAgent);
await base.OnActionExecutionAsync(context, next);
await executeAction();
}
return null;
}
#pragma warning restore CA1506
/// <summary>
/// Generic 404 response.
/// </summary>
/// <returns>An <see cref="ObjectResult"/> with <see cref="HttpStatusCode.NotFound"/>.</returns>
/// <returns>A <see cref="NotFoundObjectResult"/> with an appropriate <see cref="ErrorMessageResponse"/>.</returns>
protected new NotFoundObjectResult NotFound() => NotFound(new ErrorMessageResponse(ErrorCode.ResourceNeverPresent));
/// <summary>
/// Generic 401 response.
/// </summary>
/// <returns>An <see cref="ObjectResult"/> with <see cref="HttpStatusCode.NotFound"/>.</returns>
protected new ObjectResult Unauthorized() => this.StatusCode(HttpStatusCode.Unauthorized, null);
/// <summary>
/// Generic 501 response.
/// </summary>
@@ -264,27 +244,19 @@ namespace Tgstation.Server.Host.Controllers
/// <summary>
/// Response for missing/Invalid headers.
/// </summary>
/// <param name="ignoreMissingAuth">Whether or not errors due to missing <see cref="HeaderNames.Authorization"/> should be thrown.</param>
/// <param name="headersException">The <see cref="HeadersException"/> that occurred while trying to parse the <see cref="ApiHeaders"/>.</param>
/// <returns>The appropriate <see cref="IActionResult"/>.</returns>
protected IActionResult HeadersIssue(bool ignoreMissingAuth)
protected IActionResult HeadersIssue(HeadersException headersException)
{
HeadersException headersException;
try
{
_ = new ApiHeaders(Request.GetTypedHeaders(), ignoreMissingAuth);
if (headersException == null)
throw new InvalidOperationException("Expected a header parse exception!");
}
catch (HeadersException ex)
{
headersException = ex;
}
var errorMessage = new ErrorMessageResponse(ErrorCode.BadHeaders)
{
AdditionalData = headersException.Message,
};
if (headersException.MissingOrMalformedHeaders.HasFlag(HeaderTypes.Accept))
if (headersException.ParseErrors.HasFlag(HeaderErrorTypes.Accept))
return this.StatusCode(HttpStatusCode.NotAcceptable, errorMessage);
return BadRequest(errorMessage);
@@ -0,0 +1,48 @@
using System;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// Base class for all API style controllers.
/// </summary>
[Produces(MediaTypeNames.Application.Json)]
[ApiController]
public abstract class ApiControllerBase : Controller
{
/// <inheritdoc />
public sealed override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// never cache an API response
Response.Headers.Add(HeaderNames.CacheControl, new StringValues("no-cache"));
var errorCase = await HookExecuteAction(
() => base.OnActionExecutionAsync(context, next),
Request.HttpContext.RequestAborted);
if (errorCase != null)
await errorCase.ExecuteResultAsync(context);
}
/// <summary>
/// Hook for executing a request.
/// </summary>
/// <param name="executeAction">A <see cref="Func{TResult}"/> that should be invoked and its response awaited to continue normal execution of the request. Should NOT be called if this method returns a non-<see langword="null"/> value.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="IActionResult"/> that, if not <see langword="null"/>, is executed.</returns>
protected virtual async ValueTask<IActionResult> HookExecuteAction(Func<Task> executeAction, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(executeAction);
await executeAction();
return null;
}
}
}
@@ -4,6 +4,7 @@ using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -20,10 +21,8 @@ namespace Tgstation.Server.Host.Controllers
/// <see cref="Controller"/> for recieving DMAPI requests from DreamDaemon.
/// </summary>
[Route("/Bridge")]
[Produces(MediaTypeNames.Application.Json)]
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class BridgeController : Controller
public sealed class BridgeController : ApiControllerBase
{
/// <summary>
/// If the content of bridge requests and responses should be logged.
@@ -76,6 +75,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
[HttpGet]
[AllowAnonymous]
public async ValueTask<IActionResult> Process([FromQuery] string data, CancellationToken cancellationToken)
{
// Nothing to see here
@@ -13,12 +13,14 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -49,23 +51,26 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="ByondController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public ByondController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<ByondController> logger,
IInstanceManager instanceManager,
IJobManager jobManager,
IFileTransferTicketProvider fileTransferService)
IFileTransferTicketProvider fileTransferService,
IApiHeadersProvider apiHeadersProvider)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeadersProvider)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.fileTransferService = fileTransferService ?? throw new ArgumentNullException(nameof(fileTransferService));
@@ -190,14 +195,14 @@ namespace Tgstation.Server.Host.Controllers
Instance.Id);
// run the install through the job manager
var job = new Job
{
Description = $"Install {(!uploadingZip ? String.Empty : "custom ")}BYOND version {version}",
StartedBy = AuthenticationContext.User,
CancelRightsType = RightsType.Byond,
CancelRight = (ulong)ByondRights.CancelInstall,
Instance = Instance,
};
var job = Job.Create(
uploadingZip
? JobCode.ByondCustomInstall
: JobCode.ByondOfficialInstall,
AuthenticationContext.User,
Instance,
ByondRights.CancelInstall);
job.Description += $" {version}";
IFileUploadTicket fileUploadTicket = null;
if (uploadingZip)
@@ -299,14 +304,8 @@ namespace Tgstation.Server.Host.Controllers
var isCustomVersion = version.Build != -1;
// run the install through the job manager
var job = new Job
{
Description = $"Delete installed BYOND version {version}",
StartedBy = AuthenticationContext.User,
CancelRightsType = RightsType.Byond,
CancelRight = (ulong)(isCustomVersion ? ByondRights.InstallOfficialOrChangeActiveVersion : ByondRights.InstallCustomVersion),
Instance = Instance,
};
var job = Job.Create(JobCode.ByondDelete, AuthenticationContext.User, Instance, isCustomVersion ? ByondRights.InstallOfficialOrChangeActiveVersion : ByondRights.InstallCustomVersion);
job.Description += $" {version}";
await jobManager.RegisterOperation(
job,
@@ -19,11 +19,12 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
using Z.EntityFramework.Plus;
namespace Tgstation.Server.Host.Controllers
@@ -39,19 +40,22 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="ChatController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public ChatController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<ChatController> logger,
IInstanceManager instanceManager)
IInstanceManager instanceManager,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
}
@@ -40,18 +40,21 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <param name="instanceManager">The value of <see cref="instanceManager"/>.</param>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
/// <param name="useInstanceRequestHeader">The value of <see cref="useInstanceRequestHeader"/>.</param>
protected ComponentInterfacingController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<ComponentInterfacingController> logger,
IInstanceManager instanceManager,
bool useInstanceRequestHeader = false)
IApiHeadersProvider apiHeaders,
bool useInstanceRequestHeader)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
apiHeaders,
logger,
true)
{
@@ -12,10 +12,12 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -34,21 +36,24 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="ConfigurationController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public ConfigurationController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<ConfigurationController> logger,
IInstanceManager instanceManager,
IIOManager ioManager)
IIOManager ioManager,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
}
@@ -98,10 +103,6 @@ namespace Tgstation.Server.Host.Controllers
AdditionalData = e.Message,
});
}
catch (NotImplementedException ex)
{
return RequiresPosixSystemIdentity(ex);
}
}
/// <summary>
@@ -145,10 +146,6 @@ namespace Tgstation.Server.Host.Controllers
AdditionalData = e.Message,
});
}
catch (NotImplementedException ex)
{
return RequiresPosixSystemIdentity(ex);
}
}
/// <summary>
@@ -191,11 +188,6 @@ namespace Tgstation.Server.Host.Controllers
return new PaginatableResult<ConfigurationFileResponse>(result);
}
catch (NotImplementedException ex)
{
return new PaginatableResult<ConfigurationFileResponse>(
RequiresPosixSystemIdentity(ex));
}
catch (UnauthorizedAccessException)
{
return new PaginatableResult<ConfigurationFileResponse>(
@@ -282,10 +274,6 @@ namespace Tgstation.Server.Host.Controllers
Message = e.Message,
});
}
catch (NotImplementedException ex)
{
return RequiresPosixSystemIdentity(ex);
}
catch (UnauthorizedAccessException)
{
return Forbid();
@@ -330,10 +318,6 @@ namespace Tgstation.Server.Host.Controllers
: Conflict(new ErrorMessageResponse(ErrorCode.ConfigurationDirectoryNotEmpty));
});
}
catch (NotImplementedException ex)
{
return RequiresPosixSystemIdentity(ex);
}
catch (UnauthorizedAccessException)
{
return Forbid();
@@ -123,7 +123,7 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
/// <param name="appRoute">The value of the route.</param>
/// <returns>The <see cref="VirtualFileResult"/> to use.</returns>
[Route("{**appRoute}")]
[Route("/{**appRoute}")]
[HttpGet]
public IActionResult Get([FromRoute] string appRoute)
{
@@ -47,23 +47,26 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="DreamDaemonController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
/// <param name="portAllocator">The value of <see cref="IPortAllocator"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public DreamDaemonController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<DreamDaemonController> logger,
IInstanceManager instanceManager,
IJobManager jobManager,
IPortAllocator portAllocator)
IPortAllocator portAllocator,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator));
@@ -84,14 +87,7 @@ namespace Tgstation.Server.Host.Controllers
if (instance.Watchdog.Status != WatchdogStatus.Offline)
return Conflict(new ErrorMessageResponse(ErrorCode.WatchdogRunning));
var job = new Job
{
Description = "Launch DreamDaemon",
CancelRight = (ulong)DreamDaemonRights.Shutdown,
CancelRightsType = RightsType.DreamDaemon,
Instance = Instance,
StartedBy = AuthenticationContext.User,
};
var job = Job.Create(JobCode.WatchdogLaunch, AuthenticationContext.User, Instance, DreamDaemonRights.Shutdown);
await jobManager.RegisterOperation(
job,
(core, databaseContextFactory, paramJob, progressHandler, innerCt) => core.Watchdog.Launch(innerCt),
@@ -267,14 +263,7 @@ namespace Tgstation.Server.Host.Controllers
public ValueTask<IActionResult> Restart(CancellationToken cancellationToken)
=> WithComponentInstance(async instance =>
{
var job = new Job
{
Instance = Instance,
CancelRightsType = RightsType.DreamDaemon,
CancelRight = (ulong)DreamDaemonRights.Shutdown,
StartedBy = AuthenticationContext.User,
Description = "Restart Watchdog",
};
var job = Job.Create(JobCode.WatchdogRestart, AuthenticationContext.User, Instance, DreamDaemonRights.Shutdown);
var watchdog = instance.Watchdog;
@@ -300,14 +289,7 @@ namespace Tgstation.Server.Host.Controllers
public ValueTask<IActionResult> CreateDump(CancellationToken cancellationToken)
=> WithComponentInstance(async instance =>
{
var job = new Job
{
Instance = Instance,
CancelRightsType = RightsType.DreamDaemon,
CancelRight = (ulong)DreamDaemonRights.CreateDump,
StartedBy = AuthenticationContext.User,
Description = "Create DreamDaemon Process Dump",
};
var job = Job.Create(JobCode.WatchdogDump, AuthenticationContext.User, Instance, DreamDaemonRights.CreateDump);
var watchdog = instance.Watchdog;
@@ -13,6 +13,7 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Jobs;
@@ -43,23 +44,26 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="DreamMakerController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
/// <param name="portAllocator">The value of <see cref="IPortAllocator"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public DreamMakerController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<DreamMakerController> logger,
IInstanceManager instanceManager,
IJobManager jobManager,
IPortAllocator portAllocator)
IPortAllocator portAllocator,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator));
@@ -139,14 +143,7 @@ namespace Tgstation.Server.Host.Controllers
[ProducesResponseType(typeof(JobResponse), 202)]
public async ValueTask<IActionResult> Create(CancellationToken cancellationToken)
{
var job = new Job
{
Description = "Compile active repository code",
StartedBy = AuthenticationContext.User,
CancelRightsType = RightsType.DreamMaker,
CancelRight = (ulong)DreamMakerRights.CancelCompile,
Instance = Instance,
};
var job = Job.Create(JobCode.Deployment, AuthenticationContext.User, Instance, DreamMakerRights.CancelCompile);
await jobManager.RegisterOperation(
job,
@@ -257,6 +254,8 @@ namespace Tgstation.Server.Host.Controllers
.AsQueryable()
.Include(x => x.Job)
.ThenInclude(x => x.StartedBy)
.Include(x => x.Job)
.ThenInclude(x => x.Instance)
.Include(x => x.RevisionInformation)
.ThenInclude(x => x.PrimaryTestMerge)
.ThenInclude(x => x.MergedBy)
@@ -1,17 +1,16 @@
using System;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Octokit;
using Tgstation.Server.Api;
@@ -21,12 +20,12 @@ using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Security.OAuth;
using Tgstation.Server.Host.Swarm;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -95,7 +94,7 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="HomeController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="tokenFactory">The value of <see cref="tokenFactory"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
@@ -108,9 +107,10 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="controlPanelConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="controlPanelConfiguration"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public HomeController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ITokenFactory tokenFactory,
ISystemIdentityFactory systemIdentityFactory,
ICryptographySuite cryptographySuite,
@@ -122,10 +122,12 @@ namespace Tgstation.Server.Host.Controllers
IServerControl serverControl,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<ControlPanelConfiguration> controlPanelConfigurationOptions,
ILogger<HomeController> logger)
ILogger<HomeController> logger,
IApiHeadersProvider apiHeadersProvider)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
apiHeadersProvider,
logger,
false)
{
@@ -160,6 +162,8 @@ namespace Tgstation.Server.Host.Controllers
HeaderNames.Vary,
new StringValues(ApiHeaders.ApiVersionHeader));
// if they tried to authenticate in any form and failed, let them know immediately
bool failIfUnauthed;
if (ApiHeaders == null)
{
if (controlPanelConfiguration.Enable && !Request.Headers.TryGetValue(ApiHeaders.ApiVersionHeader, out _))
@@ -176,17 +180,20 @@ namespace Tgstation.Server.Host.Controllers
try
{
// we only allow authorization header issues
var headers = new ApiHeaders(Request.GetTypedHeaders(), true);
if (!headers.Compatible())
return this.StatusCode(
HttpStatusCode.UpgradeRequired,
new ErrorMessageResponse(ErrorCode.ApiMismatch));
ApiHeadersProvider.CreateAuthlessHeaders();
}
catch (HeadersException)
catch (HeadersException ex)
{
return HeadersIssue(true);
return HeadersIssue(ex);
}
failIfUnauthed = Request.Headers.Authorization.Any();
}
else
failIfUnauthed = ApiHeaders.Token != null;
if (failIfUnauthed && !AuthenticationContext.Valid)
return Unauthorized();
return Json(new ServerInformationResponse
{
@@ -223,8 +230,8 @@ namespace Tgstation.Server.Host.Controllers
{
if (ApiHeaders == null)
{
Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues("basic realm=\"Create TGS bearer token\""));
return HeadersIssue(false);
Response.Headers.Add(HeaderNames.WWWAuthenticate, new StringValues($"basic realm=\"Create TGS {ApiHeaders.BearerAuthenticationScheme} token\""));
return HeadersIssue(ApiHeadersProvider.HeadersException);
}
if (ApiHeaders.IsTokenAuthentication)
@@ -239,9 +246,9 @@ namespace Tgstation.Server.Host.Controllers
// trust the system over the database because a user's name can change while still having the same SID
systemIdentity = await systemIdentityFactory.CreateSystemIdentity(ApiHeaders.Username, ApiHeaders.Password, cancellationToken);
}
catch (NotImplementedException ex)
catch (NotImplementedException)
{
RequiresPosixSystemIdentity(ex);
// Intentionally suppressed
}
using (systemIdentity)
@@ -261,7 +268,7 @@ namespace Tgstation.Server.Host.Controllers
return BadRequest(new ErrorMessageResponse(ErrorCode.OAuthProviderDisabled));
externalUserId = await validator
.ValidateResponseCode(ApiHeaders.Token, cancellationToken);
.ValidateResponseCode(ApiHeaders.OAuthCode!, cancellationToken);
Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId);
}
@@ -369,11 +376,11 @@ namespace Tgstation.Server.Host.Controllers
return Forbid();
}
var token = await tokenFactory.CreateToken(user, oAuthLogin, cancellationToken);
var token = tokenFactory.CreateToken(user, oAuthLogin);
if (usingSystemIdentity)
{
// expire the identity slightly after the auth token in case of lag
var identExpiry = token.ExpiresAt;
var identExpiry = token.ParseJwt().ValidTo;
identExpiry += tokenFactory.ValidationParameters.ClockSkew;
identExpiry += TimeSpan.FromSeconds(15);
identityCache.CacheSystemIdentity(user, systemIdentity, identExpiry);
@@ -19,6 +19,7 @@ using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.IO;
@@ -67,6 +68,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly IPortAllocator portAllocator;
/// <summary>
/// The <see cref="IPermissionsUpdateNotifyee"/> for the <see cref="InstanceController"/>.
/// </summary>
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="InstanceController"/>.
/// </summary>
@@ -81,36 +87,44 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="InstanceController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/>.</param>
/// <param name="portAllocator">The value of <see cref="IPortAllocator"/>.</param>
/// <param name="portAllocator">The value of <see cref="portAllocator"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="swarmConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="swarmConfiguration"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ComponentInterfacingController"/>.</param>
public InstanceController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<InstanceController> logger,
IInstanceManager instanceManager,
IJobManager jobManager,
IIOManager ioManager,
IPortAllocator portAllocator,
IPlatformIdentifier platformIdentifier,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<SwarmConfiguration> swarmConfigurationOptions)
IOptions<SwarmConfiguration> swarmConfigurationOptions,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders,
false)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator));
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
}
@@ -262,6 +276,10 @@ namespace Tgstation.Server.Host.Controllers
newInstance.Id,
newInstance.Path);
await permissionsUpdateNotifyee.InstancePermissionSetCreated(
newInstance.InstancePermissionSets.First(),
cancellationToken);
var api = newInstance.ToApi();
api.Accessible = true; // instances are always accessible by their creator
return attached ? Json(api) : Created(api);
@@ -337,10 +355,7 @@ namespace Tgstation.Server.Host.Controllers
var moveJob = await InstanceQuery()
.SelectMany(x => x.Jobs).
Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix))
.Select(x => new Job
{
Id = x.Id,
}).FirstOrDefaultAsync(cancellationToken);
.Select(x => new Job(x.Id.Value)).FirstOrDefaultAsync(cancellationToken);
if (moveJob != default)
{
@@ -472,14 +487,9 @@ namespace Tgstation.Server.Host.Controllers
var moving = originalModelPath != null;
if (moving)
{
var job = new Job
{
Description = $"{MoveInstanceJobPrefix}{originalModel.Id} from {originalModelPath} to {rawPath}",
Instance = originalModel,
CancelRightsType = RightsType.InstanceManager,
CancelRight = (ulong)InstanceManagerRights.Relocate,
StartedBy = AuthenticationContext.User,
};
var description = $"{MoveInstanceJobPrefix}{originalModel.Id} from {originalModelPath} to {rawPath}";
var job = Job.Create(JobCode.Move, AuthenticationContext.User, originalModel, InstanceManagerRights.Relocate);
job.Description = description;
await jobManager.RegisterOperation(
job,
@@ -622,7 +632,9 @@ namespace Tgstation.Server.Host.Controllers
var moveJob = await QueryForUser()
.SelectMany(x => x.Jobs)
.Where(x => !x.StoppedAt.HasValue && x.Description.StartsWith(MoveInstanceJobPrefix))
.Include(x => x.StartedBy).ThenInclude(x => x.CreatedBy)
.Include(x => x.StartedBy)
.ThenInclude(x => x.CreatedBy)
.Include(x => x.Instance)
.FirstOrDefaultAsync(cancellationToken);
api.MoveJob = moveJob?.ToApi();
await CheckAccessible(api, cancellationToken);
@@ -763,6 +775,7 @@ namespace Tgstation.Server.Host.Controllers
{
permissionSetToModify ??= new InstancePermissionSet()
{
PermissionSet = AuthenticationContext.PermissionSet,
PermissionSetId = AuthenticationContext.PermissionSet.Id.Value,
};
permissionSetToModify.ByondRights = RightsHelper.AllRights<ByondRights>();
@@ -13,10 +13,12 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
using Z.EntityFramework.Plus;
@@ -28,24 +30,35 @@ namespace Tgstation.Server.Host.Controllers
[Route(Routes.InstancePermissionSet)]
public sealed class InstancePermissionSetController : InstanceRequiredController
{
/// <summary>
/// The <see cref="IPermissionsUpdateNotifyee"/> for the <see cref="InstancePermissionSetController"/>.
/// </summary>
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
/// <summary>
/// Initializes a new instance of the <see cref="InstancePermissionSetController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public InstancePermissionSetController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<InstancePermissionSetController> logger,
IInstanceManager instanceManager)
IInstanceManager instanceManager,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
}
/// <summary>
@@ -71,6 +84,7 @@ namespace Tgstation.Server.Host.Controllers
.Where(x => x.Id == model.PermissionSetId)
.Select(x => new Models.PermissionSet
{
Id = x.Id,
UserId = x.UserId,
})
.FirstOrDefaultAsync(cancellationToken);
@@ -107,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
@@ -3,6 +3,7 @@
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -15,19 +16,22 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="InstanceRequiredController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="ComponentInterfacingController"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ComponentInterfacingController"/>.</param>
protected InstanceRequiredController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<InstanceRequiredController> logger,
IInstanceManager instanceManager)
IInstanceManager instanceManager,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager,
apiHeaders,
true)
{
}
@@ -11,11 +11,13 @@ using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -34,21 +36,24 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="JobController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public JobController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<JobController> logger,
IInstanceManager instanceManager,
IJobManager jobManager)
IJobManager jobManager,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
}
@@ -73,6 +78,7 @@ namespace Tgstation.Server.Host.Controllers
.AsQueryable()
.Include(x => x.StartedBy)
.Include(x => x.CancelledBy)
.Include(x => x.Instance)
.Where(x => x.Instance.Id == Instance.Id && !x.StoppedAt.HasValue)
.OrderByDescending(x => x.StartedAt))),
AddJobProgressResponseTransformer,
@@ -100,6 +106,7 @@ namespace Tgstation.Server.Host.Controllers
.AsQueryable()
.Include(x => x.StartedBy)
.Include(x => x.CancelledBy)
.Include(x => x.Instance)
.Where(x => x.Instance.Id == Instance.Id)
.OrderByDescending(x => x.StartedAt))),
AddJobProgressResponseTransformer,
@@ -127,6 +134,7 @@ namespace Tgstation.Server.Host.Controllers
.Jobs
.AsQueryable()
.Include(x => x.StartedBy)
.Include(x => x.Instance)
.Where(x => x.Id == id && x.Instance.Id == Instance.Id)
.FirstOrDefaultAsync(cancellationToken);
if (job == default)
@@ -162,6 +170,7 @@ namespace Tgstation.Server.Host.Controllers
.Where(x => x.Id == id && x.Instance.Id == Instance.Id)
.Include(x => x.StartedBy)
.Include(x => x.CancelledBy)
.Include(x => x.Instance)
.FirstOrDefaultAsync(cancellationToken);
if (job == default)
return NotFound();
@@ -12,4 +12,3 @@ Some notable exceptions:
- Returns 401 If an `IAuthenticationContext` could not be created for a request.
- [BridgeController](./BridgeController.cs) is a special controller accessible only from localhost and is used to receive bridge request from DreamDaemon
- [HomeController](./HomeController.cs) contains the code to initially log in and generate an API token for a user.
- [TgsAuthorizeAttribute](./TgsAuthorizeAttribute.cs) is a special attribute applied to controller methods to define which rights are required to run a verb.
@@ -22,6 +22,7 @@ using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -46,23 +47,26 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="RepositoryController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/>.</param>
/// <param name="jobManager">The value of <see cref="jobManager"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
public RepositoryController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ILogger<RepositoryController> logger,
IInstanceManager instanceManager,
ILoggerFactory loggerFactory,
IJobManager jobManager)
IJobManager jobManager,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
logger,
instanceManager)
instanceManager,
apiHeaders)
{
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
@@ -135,20 +139,15 @@ namespace Tgstation.Server.Host.Controllers
if (repo != null)
return Conflict(new ErrorMessageResponse(ErrorCode.RepoExists));
var job = new Job
{
Description = String.Format(
var description = String.Format(
CultureInfo.InvariantCulture,
"Clone{1} repository {0}",
origin,
cloneBranch != null
? $"\"{cloneBranch}\" branch of"
: String.Empty),
StartedBy = AuthenticationContext.User,
CancelRightsType = RightsType.Repository,
CancelRight = (ulong)RepositoryRights.CancelClone,
Instance = Instance,
};
: String.Empty);
var job = Job.Create(JobCode.RepositoryClone, AuthenticationContext.User, Instance, RepositoryRights.CancelClone);
job.Description = description;
var api = currentModel.ToApi();
await DatabaseContext.Save(cancellationToken);
@@ -218,12 +217,7 @@ namespace Tgstation.Server.Host.Controllers
Logger.LogInformation("Instance {instanceId} repository delete initiated by user {userId}", Instance.Id, AuthenticationContext.User.Id.Value);
var job = new Job
{
Description = "Delete repository",
StartedBy = AuthenticationContext.User,
Instance = Instance,
};
var job = Job.Create(JobCode.RepositoryDelete, AuthenticationContext.User, Instance);
var api = currentModel.ToApi();
await jobManager.RegisterOperation(
job,
@@ -450,14 +444,8 @@ namespace Tgstation.Server.Host.Controllers
if (description == null)
return Json(api); // no git changes
var job = new Job
{
Description = description,
StartedBy = AuthenticationContext.User,
Instance = Instance,
CancelRightsType = RightsType.Repository,
CancelRight = (ulong)RepositoryRights.CancelPendingChanges,
};
var job = Job.Create(JobCode.RepositoryUpdate, AuthenticationContext.User, Instance, RepositoryRights.CancelPendingChanges);
job.Description = description;
var repositoryUpdater = new RepositoryUpdateService(
model,
@@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
using Tgstation.Server.Host.IO;
namespace Tgstation.Server.Host.Controllers
namespace Tgstation.Server.Host.Controllers.Results
{
/// <summary>
/// Very similar to <see cref="FileStreamResult"/> except it's <see cref="IActionResultExecutor{TResult}"/> contains a fix for https://github.com/dotnet/aspnetcore/issues/28189.
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Logging;
namespace Tgstation.Server.Host.Controllers
namespace Tgstation.Server.Host.Controllers.Results
{
/// <summary>
/// <see cref="IActionResultExecutor{TResult}"/> for <see cref="LimitedStreamResult"/>s.
@@ -3,7 +3,7 @@ using System.Linq;
using Microsoft.AspNetCore.Mvc;
namespace Tgstation.Server.Host.Controllers
namespace Tgstation.Server.Host.Controllers.Results
{
/// <summary>
/// Helper for returning paginated models.
@@ -5,8 +5,8 @@ using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -25,10 +25,9 @@ namespace Tgstation.Server.Host.Controllers
/// For swarm server communication.
/// </summary>
[Route(SwarmConstants.ControllerRoute)]
[Produces(MediaTypeNames.Application.Json)]
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public sealed class SwarmController : Controller
[AllowAnonymous] // We have custom private key auth
public sealed class SwarmController : ApiControllerBase
{
/// <summary>
/// Get the current registration <see cref="Guid"/> from the <see cref="ControllerBase.Request"/>.
@@ -211,7 +210,7 @@ namespace Tgstation.Server.Host.Controllers
}
/// <inheritdoc />
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
protected override async ValueTask<IActionResult> HookExecuteAction(Func<Task> executeAction, CancellationToken cancellationToken)
{
using (LogContext.PushProperty(SerilogContextHelper.RequestPathContextProperty, $"{Request.Method} {Request.Path}"))
{
@@ -219,8 +218,7 @@ namespace Tgstation.Server.Host.Controllers
if (swarmConfiguration.PrivateKey == null)
{
logger.LogDebug("Attempted swarm request without private key configured!");
await Forbid().ExecuteResultAsync(context);
return;
return Forbid();
}
if (!(Request.Headers.TryGetValue(SwarmConstants.ApiKeyHeader, out var apiKeyHeaderValues)
@@ -228,8 +226,7 @@ namespace Tgstation.Server.Host.Controllers
&& apiKeyHeaderValues.First() == swarmConfiguration.PrivateKey))
{
logger.LogDebug("Unauthorized swarm request!");
await Unauthorized().ExecuteResultAsync(context);
return;
return Unauthorized();
}
if (!(Request.Headers.TryGetValue(SwarmConstants.RegistrationIdHeader, out var registrationHeaderValues)
@@ -237,8 +234,7 @@ namespace Tgstation.Server.Host.Controllers
&& Guid.TryParse(registrationHeaderValues.First(), out var registrationId)))
{
logger.LogDebug("Swarm request without registration ID!");
await BadRequest().ExecuteResultAsync(context);
return;
return BadRequest();
}
// we validate the registration itself on a case-by-case basis
@@ -252,12 +248,12 @@ namespace Tgstation.Server.Host.Controllers
"Swarm request model validation failed!{newLine}{messages}",
Environment.NewLine,
String.Join(Environment.NewLine, errorMessages));
await BadRequest().ExecuteResultAsync(context);
return;
return BadRequest();
}
logger.LogTrace("Starting swarm request processing...");
await base.OnActionExecutionAsync(context, next);
await executeAction();
return null;
}
}
@@ -9,10 +9,12 @@ using Microsoft.Extensions.Logging;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -32,17 +34,20 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="TransferController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="fileTransferService">The value of <see cref="fileTransferService"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public TransferController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
IFileTransferStreamHandler fileTransferService,
ILogger<ApiController> logger)
ILogger<ApiController> logger,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
apiHeaders,
logger,
true)
{
@@ -15,10 +15,12 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Controllers
{
@@ -38,6 +40,11 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly ICryptographySuite cryptographySuite;
/// <summary>
/// The <see cref="IPermissionsUpdateNotifyee"/> for the <see cref="UserController"/>.
/// </summary>
readonly IPermissionsUpdateNotifyee permissionsUpdateNotifyee;
/// <summary>
/// The <see cref="GeneralConfiguration"/> for the <see cref="UserController"/>.
/// </summary>
@@ -47,26 +54,32 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="UserController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="systemIdentityFactory">The value of <see cref="systemIdentityFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/>.</param>
/// <param name="permissionsUpdateNotifyee">The value of <see cref="permissionsUpdateNotifyee"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public UserController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
ISystemIdentityFactory systemIdentityFactory,
ICryptographySuite cryptographySuite,
IPermissionsUpdateNotifyee permissionsUpdateNotifyee,
ILogger<UserController> logger,
IOptions<GeneralConfiguration> generalConfigurationOptions)
IOptions<GeneralConfiguration> generalConfigurationOptions,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
authenticationContext,
apiHeaders,
logger,
true)
{
this.systemIdentityFactory = systemIdentityFactory ?? throw new ArgumentNullException(nameof(systemIdentityFactory));
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.permissionsUpdateNotifyee = permissionsUpdateNotifyee ?? throw new ArgumentNullException(nameof(permissionsUpdateNotifyee));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
@@ -238,13 +251,17 @@ namespace Tgstation.Server.Host.Controllers
if (model.Name != null && Models.User.CanonicalizeName(model.Name) != originalUser.CanonicalName)
return BadRequest(new ErrorMessageResponse(ErrorCode.UserNameChange));
bool userWasDisabled;
if (model.Enabled.HasValue)
{
if (originalUser.Enabled.Value && !model.Enabled.Value)
userWasDisabled = originalUser.Enabled.Value && !model.Enabled.Value;
if (userWasDisabled)
originalUser.LastPasswordUpdate = DateTimeOffset.UtcNow;
originalUser.Enabled = model.Enabled.Value;
}
else
userWasDisabled = false;
if (model.OAuthConnections != null
&& (model.OAuthConnections.Count != originalUser.OAuthConnections.Count
@@ -280,7 +297,7 @@ namespace Tgstation.Server.Host.Controllers
DatabaseContext.Groups.Attach(originalUser.Group);
if (originalUser.PermissionSet != null)
{
Logger.LogInformation("Deleting permission set {0}...", originalUser.PermissionSet.Id);
Logger.LogInformation("Deleting permission set {permissionSetId}...", originalUser.PermissionSet.Id);
DatabaseContext.PermissionSets.Remove(originalUser.PermissionSet);
originalUser.PermissionSet = null;
}
@@ -308,7 +325,10 @@ namespace Tgstation.Server.Host.Controllers
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Updated user {0} ({1})", originalUser.Name, originalUser.Id);
Logger.LogInformation("Updated user {userName} ({userId})", originalUser.Name, originalUser.Id);
if (userWasDisabled)
await permissionsUpdateNotifyee.UserDisabled(originalUser, cancellationToken);
// return id only if not a self update and cannot read users
var canReadBack = AuthenticationContext.User.Id == originalUser.Id
@@ -14,10 +14,12 @@ using Tgstation.Server.Api.Models.Request;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.Utils;
using Z.EntityFramework.Plus;
@@ -38,19 +40,22 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="UserGroupController"/> class.
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ApiController"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/>.</param>
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="ApiController"/>.</param>
public UserGroupController(
IDatabaseContext databaseContext,
IAuthenticationContextFactory authenticationContextFactory,
IAuthenticationContext authenticationContext,
IOptions<GeneralConfiguration> generalConfigurationOptions,
ILogger<UserGroupController> logger)
ILogger<UserGroupController> logger,
IApiHeadersProvider apiHeaders)
: base(
databaseContext,
authenticationContextFactory,
logger,
true)
databaseContext,
authenticationContext,
apiHeaders,
logger,
true)
{
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
}
@@ -93,7 +98,7 @@ namespace Tgstation.Server.Host.Controllers
DatabaseContext.Groups.Add(dbGroup);
await DatabaseContext.Save(cancellationToken);
Logger.LogInformation("Created new user group {0} ({1})", dbGroup.Name, dbGroup.Id);
Logger.LogInformation("Created new user group {groupName} ({groupId})", dbGroup.Name, dbGroup.Id);
return Created(dbGroup.ToApi(true));
}
+149 -65
View File
@@ -2,17 +2,23 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Cyberboss.AspNetCore.AsyncInitializer;
using Elastic.CommonSchema.Serilog;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -27,6 +33,7 @@ using Serilog.Formatting.Display;
using Serilog.Sinks.Elasticsearch;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Hubs;
using Tgstation.Server.Common.Http;
using Tgstation.Server.Host.Components;
using Tgstation.Server.Host.Components.Byond;
@@ -39,6 +46,7 @@ using Tgstation.Server.Host.Components.Session;
using Tgstation.Server.Host.Components.Watchdog;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Extensions.Converters;
@@ -113,7 +121,7 @@ namespace Tgstation.Server.Host.Core
}
/// <summary>
/// Configure the <see cref="Application"/>'s services.
/// Configure the <see cref="Application"/>'s <paramref name="services"/>.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> needed for configuration.</param>
@@ -215,50 +223,48 @@ namespace Tgstation.Server.Host.Core
postSetupServices.InternalConfiguration,
postSetupServices.FileLoggingConfiguration);
// configure bearer token validation
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwtBearerOptions =>
{
// this line isn't actually run until the first request is made
// at that point tokenFactory will be populated
jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters;
jwtBearerOptions.MapInboundClaims = false;
jwtBearerOptions.Events = new JwtBearerEvents
{
// Application is our composition root so this monstrosity of a line is okay
// At least, that's what I tell myself to sleep at night
OnTokenValidated = ctx => ctx
.HttpContext
.RequestServices
.GetRequiredService<IClaimsInjector>()
.InjectClaimsIntoContext(
ctx,
ctx.HttpContext.RequestAborted),
};
});
// configure authentication pipeline
ConfigureAuthenticationPipeline(services);
// add mvc, configure the json serializer settings
var jsonVersionConverterList = new List<JsonConverter>
{
new VersionConverter(),
};
void ConfigureNewtonsoftJsonSerializerSettingsForApi(JsonSerializerSettings settings)
{
settings.NullValueHandling = NullValueHandling.Ignore;
settings.CheckAdditionalContent = true;
settings.MissingMemberHandling = MissingMemberHandling.Error;
settings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
settings.Converters = jsonVersionConverterList;
}
services
.AddMvc(options =>
{
options.EnableEndpointRouting = false;
options.ReturnHttpNotAcceptable = true;
options.RespectBrowserAcceptHeader = true;
})
.AddNewtonsoftJson(options =>
{
options.AllowInputFormatterExceptionMessages = true;
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
options.SerializerSettings.CheckAdditionalContent = true;
options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error;
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
options.SerializerSettings.Converters = new List<JsonConverter>
{
new VersionConverter(),
};
ConfigureNewtonsoftJsonSerializerSettingsForApi(options.SerializerSettings);
});
services.AddSignalR(
options =>
{
options.AddFilter<AuthorizationContextHubFilter>();
})
.AddNewtonsoftJsonProtocol(options =>
{
ConfigureNewtonsoftJsonSerializerSettingsForApi(options.PayloadSerializerSettings);
});
services.AddHub<JobsHub, IJobsHub>();
if (postSetupServices.GeneralConfiguration.HostApiDocumentation)
{
string GetDocumentationFilePath(string assemblyLocation) => ioManager.ConcatPath(ioManager.GetDirectoryName(assemblyLocation), String.Concat(ioManager.GetFileNameWithoutExtension(assemblyLocation), ".xml"));
@@ -317,9 +323,7 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton<IDatabaseContextFactory, DatabaseContextFactory>();
services.AddSingleton<IDatabaseSeeder, DatabaseSeeder>();
// configure security services
services.AddScoped<IAuthenticationContextFactory, AuthenticationContextFactory>();
services.AddScoped<IClaimsInjector, ClaimsInjector>();
// configure other security services
services.AddSingleton<IOAuthProviders, OAuthProviders>();
services.AddSingleton<IIdentityCache, IdentityCache>();
services.AddSingleton<ICryptographySuite, CryptographySuite>();
@@ -395,8 +399,11 @@ namespace Tgstation.Server.Host.Core
services.AddGitHub();
// configure root services
services.AddSingleton<IJobService, JobService>();
services.AddSingleton<JobService>();
services.AddSingleton<IJobService>(provider => provider.GetRequiredService<JobService>());
services.AddSingleton<IJobsHubUpdater>(provider => provider.GetRequiredService<JobService>());
services.AddSingleton<IJobManager>(x => x.GetRequiredService<IJobService>());
services.AddSingleton<IPermissionsUpdateNotifyee, JobsHubGroupMapper>();
services.AddSingleton<InstanceManager>();
services.AddSingleton<IBridgeDispatcher>(x => x.GetRequiredService<InstanceManager>());
@@ -467,30 +474,6 @@ namespace Tgstation.Server.Host.Core
logger.LogTrace("Swagger API generation enabled");
}
// Set up CORS based on configuration if necessary
Action<CorsPolicyBuilder> corsBuilder = null;
if (controlPanelConfiguration.AllowAnyOrigin)
{
logger.LogTrace("Access-Control-Allow-Origin: *");
corsBuilder = builder => builder.AllowAnyOrigin();
}
else if (controlPanelConfiguration.AllowedOrigins?.Count > 0)
{
logger.LogTrace("Access-Control-Allow-Origin: {allowedOrigins}", String.Join(',', controlPanelConfiguration.AllowedOrigins));
corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray());
}
var originalBuilder = corsBuilder;
corsBuilder = builder =>
{
builder
.AllowAnyHeader()
.AllowAnyMethod()
.SetPreflightMaxAge(TimeSpan.FromDays(1));
originalBuilder?.Invoke(builder);
};
applicationBuilder.UseCors(corsBuilder);
// spa loading if necessary
if (controlPanelConfiguration.Enable)
{
@@ -504,22 +487,68 @@ namespace Tgstation.Server.Host.Core
}
else
#if NO_WEBPANEL
logger.LogTrace("Web control panel was not included in TGS build!");
logger.LogDebug("Web control panel was not included in TGS build!");
#else
logger.LogTrace("Web control panel disabled!");
#endif
// Do not cache a single thing beyond this point, it's all API
applicationBuilder.UseDisabledClientCache();
// Enable endpoint routing
applicationBuilder.UseRouting();
// Set up CORS based on configuration if necessary
Action<CorsPolicyBuilder> corsBuilder = null;
if (controlPanelConfiguration.AllowAnyOrigin)
{
logger.LogTrace("Access-Control-Allow-Origin: *");
corsBuilder = builder => builder.SetIsOriginAllowed(_ => true);
}
else if (controlPanelConfiguration.AllowedOrigins?.Count > 0)
{
logger.LogTrace("Access-Control-Allow-Origin: {allowedOrigins}", String.Join(',', controlPanelConfiguration.AllowedOrigins));
corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray());
}
var originalBuilder = corsBuilder;
corsBuilder = builder =>
{
builder
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.SetPreflightMaxAge(TimeSpan.FromDays(1));
originalBuilder?.Invoke(builder);
};
applicationBuilder.UseCors(corsBuilder);
// validate the API version
applicationBuilder.UseApiCompatibility();
// authenticate JWT tokens using our security pipeline if present, returns 401 if bad
applicationBuilder.UseAuthentication();
// enable authorization on endpoints
applicationBuilder.UseAuthorization();
// suppress and log database exceptions
applicationBuilder.UseDbConflictHandling();
// majority of handling is done in the controllers
applicationBuilder.UseMvc();
// setup endpoints
applicationBuilder.UseEndpoints(endpoints =>
{
// access to the signalR jobs hub
endpoints.MapHub<JobsHub>(
Routes.JobsHub,
options =>
{
options.Transports = HttpTransportType.ServerSentEvents;
options.CloseOnAuthenticationExpiration = true;
})
.RequireAuthorization()
.RequireCors(corsBuilder);
// majority of handling is done in the controllers
endpoints.MapControllers();
});
// 404 anything that gets this far
// End of request pipeline setup
@@ -534,5 +563,60 @@ namespace Tgstation.Server.Host.Core
/// <inheritdoc />
protected override void ConfigureHostedService(IServiceCollection services)
=> services.AddSingleton<IHostedService>(x => x.GetRequiredService<InstanceManager>());
/// <summary>
/// Configure the <paramref name="services"/> for the authentication pipeline.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to configure.</param>
void ConfigureAuthenticationPipeline(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddScoped<IApiHeadersProvider, ApiHeadersProvider>();
services.AddScoped<AuthenticationContextFactory>();
services.AddScoped<IAuthenticationContextFactory>(provider => provider.GetRequiredService<AuthenticationContextFactory>());
// what if you
// wanted to just do this:
// return provider.GetRequiredService<AuthenticationContextFactory>().CurrentAuthenticationContext
// But M$ said
// https://stackoverflow.com/questions/56792917/scoped-services-in-asp-net-core-with-signalr-hubs
services.AddScoped(provider => provider
.GetRequiredService<IHttpContextAccessor>()
.HttpContext
.RequestServices
.GetRequiredService<AuthenticationContextFactory>()
.CurrentAuthenticationContext);
services.AddScoped<IClaimsTransformation, AuthenticationContextClaimsTransformation>();
services.AddScoped<IAuthorizationFilter, AuthenticationContextAuthorizationFilter>();
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(jwtBearerOptions =>
{
// this line isn't actually run until the first request is made
// at that point tokenFactory will be populated
jwtBearerOptions.TokenValidationParameters = tokenFactory?.ValidationParameters ?? throw new InvalidOperationException("tokenFactory not initialized!");
jwtBearerOptions.MapInboundClaims = false;
jwtBearerOptions.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (String.IsNullOrWhiteSpace(context.Token))
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!String.IsNullOrWhiteSpace(accessToken) &&
path.StartsWithSegments(Routes.HubsRoot, StringComparison.OrdinalIgnoreCase))
{
context.Token = accessToken;
}
}
return Task.CompletedTask;
},
};
});
}
}
}
@@ -378,22 +378,22 @@ namespace Tgstation.Server.Host.Database
/// <summary>
/// Used by unit tests to remind us to setup the correct MSSQL migration downgrades.
/// </summary>
internal static readonly Type MSLatestMigration = typeof(MSAddMapThreads);
internal static readonly Type MSLatestMigration = typeof(MSAddJobCodes);
/// <summary>
/// Used by unit tests to remind us to setup the correct MYSQL migration downgrades.
/// </summary>
internal static readonly Type MYLatestMigration = typeof(MYAddMapThreads);
internal static readonly Type MYLatestMigration = typeof(MYAddJobCodes);
/// <summary>
/// Used by unit tests to remind us to setup the correct PostgresSQL migration downgrades.
/// </summary>
internal static readonly Type PGLatestMigration = typeof(PGAddMapThreads);
internal static readonly Type PGLatestMigration = typeof(PGAddJobCodes);
/// <summary>
/// Used by unit tests to remind us to setup the correct SQLite migration downgrades.
/// </summary>
internal static readonly Type SLLatestMigration = typeof(SLAddMapThreads);
internal static readonly Type SLLatestMigration = typeof(SLAddJobCodes);
/// <inheritdoc />
#pragma warning disable CA1502 // Cyclomatic complexity
@@ -422,6 +422,15 @@ namespace Tgstation.Server.Host.Database
string BadDatabaseType() => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType));
if (targetVersion < new Version(5, 17, 0))
targetMigration = currentDatabaseType switch
{
DatabaseType.MySql => nameof(MYAddMapThreads),
DatabaseType.PostgresSql => nameof(PGAddMapThreads),
DatabaseType.SqlServer => nameof(MSAddMapThreads),
DatabaseType.Sqlite => nameof(SLAddMapThreads),
_ => BadDatabaseType(),
};
if (targetVersion < new Version(5, 13, 0))
targetMigration = currentDatabaseType switch
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class MSAddJobCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<byte>(
name: "JobCode",
table: "Jobs",
type: "tinyint",
nullable: false,
defaultValue: (byte)0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "JobCode",
table: "Jobs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class MYAddJobCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<byte>(
name: "JobCode",
table: "Jobs",
type: "tinyint unsigned",
nullable: false,
defaultValue: (byte)0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "JobCode",
table: "Jobs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class PGAddJobCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<byte>(
name: "JobCode",
table: "Jobs",
type: "smallint",
nullable: false,
defaultValue: (byte)0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "JobCode",
table: "Jobs");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tgstation.Server.Host.Database.Migrations
{
/// <inheritdoc />
public partial class SLAddJobCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.AddColumn<byte>(
name: "JobCode",
table: "Jobs",
type: "INTEGER",
nullable: false,
defaultValue: (byte)0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
ArgumentNullException.ThrowIfNull(migrationBuilder);
migrationBuilder.DropColumn(
name: "JobCode",
table: "Jobs");
}
}
}
@@ -11,12 +11,11 @@ namespace Tgstation.Server.Host.Database.Migrations
[DbContext(typeof(MySqlDatabaseContext))]
partial class MySqlDatabaseContextModelSnapshot : ModelSnapshot
{
/// <inheritdoc />
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.7")
.HasAnnotation("ProductVersion", "7.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
@@ -416,6 +415,9 @@ namespace Tgstation.Server.Host.Database.Migrations
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<byte>("JobCode")
.HasColumnType("tinyint unsigned");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("datetime(6)");
@@ -11,12 +11,11 @@ namespace Tgstation.Server.Host.Database.Migrations
[DbContext(typeof(PostgresSqlDatabaseContext))]
partial class PostgresSqlDatabaseContextModelSnapshot : ModelSnapshot
{
/// <inheritdoc />
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.7")
.HasAnnotation("ProductVersion", "7.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -396,6 +395,9 @@ namespace Tgstation.Server.Host.Database.Migrations
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<byte>("JobCode")
.HasColumnType("smallint");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("timestamp with time zone");
@@ -11,12 +11,11 @@ namespace Tgstation.Server.Host.Database.Migrations
[DbContext(typeof(SqlServerDatabaseContext))]
partial class SqlServerDatabaseContextModelSnapshot : ModelSnapshot
{
/// <inheritdoc />
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.7")
.HasAnnotation("ProductVersion", "7.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -399,6 +398,9 @@ namespace Tgstation.Server.Host.Database.Migrations
b.Property<long>("InstanceId")
.HasColumnType("bigint");
b.Property<byte>("JobCode")
.HasColumnType("tinyint");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("datetimeoffset");
@@ -11,11 +11,10 @@ namespace Tgstation.Server.Host.Database.Migrations
[DbContext(typeof(SqliteDatabaseContext))]
partial class SqliteDatabaseContextModelSnapshot : ModelSnapshot
{
/// <inheritdoc />
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.7");
modelBuilder.HasAnnotation("ProductVersion", "7.0.13");
modelBuilder.Entity("Tgstation.Server.Host.Models.ChatBot", b =>
{
@@ -386,6 +385,9 @@ namespace Tgstation.Server.Host.Database.Migrations
b.Property<long>("InstanceId")
.HasColumnType("INTEGER");
b.Property<byte>("JobCode")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("StartedAt")
.IsRequired()
.HasColumnType("TEXT");
@@ -8,8 +8,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Serilog.Context;
@@ -66,20 +64,6 @@ namespace Tgstation.Server.Host.Extensions
});
}
/// <summary>
/// Suppress any client side caching of API calls.
/// </summary>
/// <param name="applicationBuilder">The <see cref="IApplicationBuilder"/> to configure.</param>
public static void UseDisabledClientCache(this IApplicationBuilder applicationBuilder)
{
ArgumentNullException.ThrowIfNull(applicationBuilder);
applicationBuilder.Use(async (context, next) =>
{
context.Response.Headers.Add(HeaderNames.CacheControl, new StringValues("no-cache"));
await next();
});
}
/// <summary>
/// Suppress <see cref="global::System.Threading.Tasks.TaskCanceledException"/> warnings when a user aborts a request.
/// </summary>
@@ -135,6 +119,35 @@ namespace Tgstation.Server.Host.Extensions
});
}
/// <summary>
/// Check that the API version is the current major version if it's present in the headers.
/// </summary>
/// <param name="applicationBuilder">The <see cref="IApplicationBuilder"/> to configure.</param>
public static void UseApiCompatibility(this IApplicationBuilder applicationBuilder)
{
ArgumentNullException.ThrowIfNull(applicationBuilder);
applicationBuilder.Use(async (context, next) =>
{
var apiHeadersProvider = context.RequestServices.GetRequiredService<IApiHeadersProvider>();
if (apiHeadersProvider.ApiHeaders?.Compatible() == false)
{
await new JsonResult(
new ErrorMessageResponse(ErrorCode.ApiMismatch))
{
StatusCode = (int)HttpStatusCode.UpgradeRequired,
}
.ExecuteResultAsync(new ActionContext
{
HttpContext = context,
});
return;
}
await next();
});
}
/// <summary>
/// Add the X-Powered-By response header.
/// </summary>
@@ -11,7 +11,7 @@ using Microsoft.Net.Http.Headers;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Controllers.Results;
using Tgstation.Server.Host.Transfer;
namespace Tgstation.Server.Host.Extensions
@@ -16,6 +16,7 @@ using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Utils;
using Tgstation.Server.Host.Utils.GitHub;
using Tgstation.Server.Host.Utils.SignalR;
namespace Tgstation.Server.Host.Extensions
{
@@ -220,6 +221,23 @@ namespace Tgstation.Server.Host.Extensions
});
}
/// <summary>
/// Attempt to add the given <typeparamref name="THub"/> to services.
/// </summary>
/// <typeparam name="THub">The <see cref="Type"/> of the <see cref="Microsoft.AspNetCore.SignalR.Hub{T}"/> being added.</typeparam>
/// <typeparam name="THubMethods">The implementation <see cref="Type"/> of the <typeparamref name="THub"/>.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the <typeparamref name="THub"/> to.</param>
public static void AddHub<THub, THubMethods>(this IServiceCollection services)
where THub : ConnectionMappingHub<THub, THubMethods>
where THubMethods : class
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton(typeof(ComprehensiveHubContext<,>));
services.AddSingleton<IConnectionMappedHubContext<THub, THubMethods>>(provider => provider.GetRequiredService<ComprehensiveHubContext<THub, THubMethods>>());
services.AddSingleton<IHubConnectionMapper<THub, THubMethods>>(provider => provider.GetRequiredService<ComprehensiveHubContext<THub, THubMethods>>());
}
/// <summary>
/// Set the modifiable services to their default types.
/// </summary>
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Host.Jobs
{
/// <summary>
/// Allows manually triggering jobs hub updates.
/// </summary>
interface IJobsHubUpdater
{
/// <summary>
/// Queue a message to be sent to all clients with the current state of active jobs.
/// </summary>
void QueueActiveJobUpdates();
}
}
+150 -32
View File
@@ -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
{
/// <inheritdoc cref="IJobService" />
sealed class JobService : IJobService, IDisposable
sealed class JobService : IJobService, IJobsHubUpdater, IDisposable
{
/// <summary>
/// The maximum rate at which hub clients can receive updates.
/// </summary>
const int MaxHubUpdatesPerSecond = 4;
/// <summary>
/// The <see cref="IHubContext"/> for the <see cref="JobsHub"/>.
/// </summary>
readonly IConnectionMappedHubContext<JobsHub, IJobsHub> hub;
/// <summary>
/// The <see cref="IServiceProvider"/> for the <see cref="JobService"/>.
/// </summary>
@@ -40,6 +56,11 @@ namespace Tgstation.Server.Host.Jobs
/// </summary>
readonly Dictionary<long, JobHandler> jobs;
/// <summary>
/// <see cref="Dictionary{TKey, TValue}"/> of running <see cref="Job"/> <see cref="Api.Models.EntityId.Id"/>s to <see cref="Action"/>s that will push immediate <see cref="hub"/> updates.
/// </summary>
readonly Dictionary<long, Action> hubUpdateActions;
/// <summary>
/// <see cref="TaskCompletionSource{TResult}"/> to delay starting jobs until the server is ready.
/// </summary>
@@ -63,18 +84,23 @@ namespace Tgstation.Server.Host.Jobs
/// <summary>
/// Initializes a new instance of the <see cref="JobService"/> class.
/// </summary>
/// <param name="hub">The value of <see cref="hub"/>.</param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/>.</param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public JobService(
IConnectionMappedHubContext<JobsHub, IJobsHub> hub,
IDatabaseContextFactory databaseContextFactory,
ILoggerFactory loggerFactory,
ILogger<JobService> 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<long, JobHandler>();
hubUpdateActions = new Dictionary<long, Action>();
activationTcs = new TaskCompletionSource<IInstanceCoreProvider>();
synchronizationLock = new object();
addCancelLock = new object();
@@ -100,7 +126,7 @@ namespace Tgstation.Server.Host.Jobs
job.Instance = new Models.Instance
{
Id = job.Instance.Id,
Id = job.Instance.Id.Value,
};
databaseContext.Instances.Attach(job.Instance);
@@ -111,7 +137,7 @@ namespace Tgstation.Server.Host.Jobs
else
job.StartedBy = new User
{
Id = job.StartedBy.Id,
Id = job.StartedBy.Id ?? throw new InvalidOperationException("StartedBy User associated with job does not have an Id!"),
};
databaseContext.Users.Attach(job.StartedBy);
@@ -152,14 +178,14 @@ namespace Tgstation.Server.Host.Jobs
.Jobs
.AsQueryable()
.Where(y => !y.StoppedAt.HasValue)
.Select(y => y.Id)
.Select(y => y.Id.Value)
.ToListAsync(cancellationToken);
if (badJobIds.Count > 0)
{
logger.LogTrace("Cleaning {unfinishedJobCount} unfinished jobs...", badJobIds.Count);
foreach (var badJobId in badJobIds)
{
var job = new Job { Id = badJobId };
var job = new Job(badJobId);
databaseContext.Jobs.Attach(job);
job.Cancelled = true;
job.StoppedAt = DateTimeOffset.UtcNow;
@@ -181,10 +207,7 @@ namespace Tgstation.Server.Host.Jobs
{
noMoreJobsShouldStart = true;
joinTasks = jobs.Select(x => CancelJob(
new Job
{
Id = x.Key,
},
new Job(x.Key),
null,
true,
cancellationToken))
@@ -214,7 +237,7 @@ namespace Tgstation.Server.Host.Jobs
{
user ??= await databaseContext.Users.GetTgsUser(cancellationToken);
var updatedJob = new Job { Id = job.Id };
var updatedJob = new Job(job.Id.Value);
databaseContext.Jobs.Attach(updatedJob);
var attachedUser = new User { Id = user.Id };
databaseContext.Users.Attach(attachedUser);
@@ -269,13 +292,12 @@ namespace Tgstation.Server.Host.Jobs
if (noMoreJobsShouldStart && !handler.Started)
await Extensions.TaskExtensions.InfiniteTask.WaitAsync(cancellationToken);
ValueTask<Job>? cancelTask = null;
var cancelTask = ValueTask.FromResult<Job>(null);
bool result;
using (jobCancellationToken.Register(() => cancelTask = CancelJob(job, canceller, true, cancellationToken)))
result = await handler.Wait(cancellationToken);
if (cancelTask.HasValue)
await cancelTask.Value;
await cancelTask;
return result;
}
@@ -289,26 +311,83 @@ namespace Tgstation.Server.Host.Jobs
activationTcs.SetResult(instanceCoreProvider);
}
/// <inheritdoc />
public void QueueActiveJobUpdates()
{
lock (hubUpdateActions)
foreach (var action in hubUpdateActions.Values)
action();
}
/// <summary>
/// Runner for <see cref="JobHandler"/>s.
/// </summary>
/// <param name="job">The <see cref="Job"/> being run.</param>
/// <param name="job">The <see cref="Job"/> being run. Must be fully populated.</param>
/// <param name="operation">The <see cref="JobEntrypoint"/> for the <paramref name="job"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
#pragma warning disable CA1506 // TODO: Decomplexify
async Task<bool> RunJob(Job job, JobEntrypoint operation, CancellationToken cancellationToken)
#pragma warning restore CA1506
{
using (LogContext.PushProperty(SerilogContextHelper.JobIdContextProperty, job.Id))
try
{
void LogException(Exception ex) => logger.LogDebug(ex, "Job {jobId} exited with error!", job.Id);
var hubUpdatesTask = Task.CompletedTask;
var result = false;
Stopwatch stopwatch = null;
void QueueHubUpdate(JobResponse update, bool final)
{
void NextUpdate(bool bypassRate)
{
var currentUpdatesTask = hubUpdatesTask;
async Task ChainHubUpdate()
{
await currentUpdatesTask;
// DCT: Cancellation token is for job, operation should always run
await hub
.Clients
.Group(JobsHub.HubGroupName(job))
.ReceiveJobUpdate(update, CancellationToken.None);
}
Stopwatch enteredLock = null;
try
{
if (!bypassRate && stopwatch != null)
{
Monitor.Enter(stopwatch);
enteredLock = stopwatch;
if (stopwatch.ElapsedMilliseconds * MaxHubUpdatesPerSecond < 1)
return; // don't spam client
}
hubUpdatesTask = ChainHubUpdate();
stopwatch = Stopwatch.StartNew();
}
finally
{
if (enteredLock != null)
Monitor.Exit(enteredLock);
}
}
var jobId = update.Id.Value;
lock (hubUpdateActions)
if (final)
hubUpdateActions.Remove(jobId);
else
hubUpdateActions[jobId] = () => NextUpdate(true);
NextUpdate(false);
}
try
{
var oldJob = job;
job = new Job { Id = oldJob.Id };
void UpdateProgress(string stage, double? progress)
{
if (progress.HasValue
@@ -319,19 +398,26 @@ namespace Tgstation.Server.Host.Jobs
return;
}
int? newProgress = progress.HasValue ? (int)Math.Floor(progress.Value * 100) : null;
lock (synchronizationLock)
if (jobs.TryGetValue(oldJob.Id.Value, out var handler))
if (jobs.TryGetValue(job.Id.Value, out var handler))
{
handler.Stage = stage;
handler.Progress = progress.HasValue ? (int)Math.Floor(progress.Value * 100) : null;
handler.Progress = newProgress;
var updatedJob = job.ToApi();
updatedJob.Stage = stage;
updatedJob.Progress = newProgress;
QueueHubUpdate(updatedJob, false);
}
}
var instanceCoreProvider = await activationTcs.Task.WaitAsync(cancellationToken);
QueueHubUpdate(job.ToApi(), false);
logger.LogTrace("Starting job...");
await operation(
instanceCoreProvider.GetInstance(oldJob.Instance),
instanceCoreProvider.GetInstance(job.Instance),
databaseContextFactory,
job,
new JobProgressReporter(
@@ -360,22 +446,54 @@ namespace Tgstation.Server.Host.Jobs
LogException(e);
}
await databaseContextFactory.UseContext(async databaseContext =>
try
{
var attachedJob = new Job
await databaseContextFactory.UseContext(async databaseContext =>
{
Id = job.Id,
};
var attachedJob = new Job(job.Id.Value);
databaseContext.Jobs.Attach(attachedJob);
attachedJob.StoppedAt = DateTimeOffset.UtcNow;
attachedJob.ExceptionDetails = job.ExceptionDetails;
attachedJob.ErrorCode = job.ErrorCode;
attachedJob.Cancelled = job.Cancelled;
databaseContext.Jobs.Attach(attachedJob);
attachedJob.StoppedAt = DateTimeOffset.UtcNow;
attachedJob.ExceptionDetails = job.ExceptionDetails;
attachedJob.ErrorCode = job.ErrorCode;
attachedJob.Cancelled = job.Cancelled;
// DCT: Cancellation token is for job, operation should always run
await databaseContext.Save(CancellationToken.None);
});
// DCT: Cancellation token is for job, operation should always run
await databaseContext.Save(CancellationToken.None);
});
// Resetting the context here because I CBA to worry if the cache is being used
await databaseContextFactory.UseContext(async databaseContext =>
{
// Cancellation might be set in another async context, forced to reload here for the final hub update
// DCT: Cancellation token is for job, operation should always run
var finalJob = await databaseContext
.Jobs
.AsQueryable()
.Include(x => x.Instance)
.Include(x => x.StartedBy)
.Include(x => x.CancelledBy)
.Where(dbJob => dbJob.Id == job.Id.Value)
.FirstAsync(CancellationToken.None);
QueueHubUpdate(finalJob.ToApi(), true);
});
}
catch
{
lock (hubUpdateActions)
hubUpdateActions.Remove(job.Id.Value);
throw;
}
try
{
await hubUpdatesTask;
}
catch (Exception ex)
{
logger.LogError(ex, "Error in hub updates chain task!");
}
return result;
}
+52
View File
@@ -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
{
/// <summary>
/// A SignalR <see cref="Hub"/> for pushing job updates.
/// </summary>
sealed class JobsHub : ConnectionMappingHub<JobsHub, IJobsHub>
{
/// <summary>
/// Get the group name for a given <paramref name="instanceId"/>.
/// </summary>
/// <param name="instanceId">The <see cref="Instance"/> <see cref="Api.Models.EntityId.Id"/>.</param>
/// <returns>The name of the group for the <paramref name="instanceId"/>.</returns>
public static string HubGroupName(long instanceId)
=> $"instance-{instanceId}";
/// <summary>
/// Get the group name for a given <paramref name="job"/>.
/// </summary>
/// <param name="job">The <see cref="Job"/>.</param>
/// <returns>The name of the group for the <paramref name="job"/>.</returns>
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);
}
/// <summary>
/// Initializes a new instance of the <see cref="JobsHub"/> class.
/// </summary>
/// <param name="connectionMapper">The <see cref="IHubConnectionMapper{THub, THubMethods}"/> for the <see cref="ConnectionMappingHub{TChildHub, THubMethods}"/>.</param>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="ConnectionMappingHub{TChildHub, THubMethods}"/>.</param>
public JobsHub(
IHubConnectionMapper<JobsHub, IJobsHub> connectionMapper,
IAuthenticationContext authenticationContext)
: base(connectionMapper, authenticationContext)
{
}
}
}
@@ -0,0 +1,192 @@
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
{
/// <summary>
/// Handles mapping groups for the <see cref="JobsHub"/>.
/// </summary>
sealed class JobsHubGroupMapper : IPermissionsUpdateNotifyee
{
/// <summary>
/// The <see cref="IHubContext"/> for the <see cref="JobsHub"/>.
/// </summary>
readonly IConnectionMappedHubContext<JobsHub, IJobsHub> hub;
/// <summary>
/// The <see cref="IDatabaseContextFactory"/> for the <see cref="JobService"/>.
/// </summary>
readonly IDatabaseContextFactory databaseContextFactory;
/// <summary>
/// The <see cref="IJobsHubUpdater"/> for the <see cref="JobService"/>.
/// </summary>
readonly IJobsHubUpdater jobsHubUpdater;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="JobService"/>.
/// </summary>
readonly ILogger<JobsHubGroupMapper> logger;
/// <summary>
/// Initializes a new instance of the <see cref="JobsHubGroupMapper"/> class.
/// </summary>
/// <param name="hub">The value of <see cref="hub"/>.</param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/>.</param>
/// <param name="jobsHubUpdater">The value of <see cref="jobsHubUpdater"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public JobsHubGroupMapper(
IConnectionMappedHubContext<JobsHub, IJobsHub> hub,
IDatabaseContextFactory databaseContextFactory,
IJobsHubUpdater jobsHubUpdater,
ILogger<JobsHubGroupMapper> logger)
{
this.hub = hub ?? throw new ArgumentNullException(nameof(hub));
this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory));
this.jobsHubUpdater = jobsHubUpdater ?? throw new ArgumentNullException(nameof(jobsHubUpdater));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
hub.OnConnectionMapGroups += MapConnectionGroups;
}
/// <inheritdoc />
public ValueTask InstancePermissionSetCreated(InstancePermissionSet instancePermissionSet, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(instancePermissionSet);
var permissionSetId = instancePermissionSet.PermissionSet.Id ?? instancePermissionSet.PermissionSetId;
logger.LogTrace("InstancePermissionSetCreated");
return RefreshHubGroups(
permissionSetId,
cancellationToken);
}
/// <inheritdoc />
public ValueTask UserDisabled(User user, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
if (!user.Id.HasValue)
throw new InvalidOperationException("user.Id was null!");
logger.LogTrace("UserDisabled");
hub.AbortUnauthedConnections(user);
return ValueTask.CompletedTask;
}
/// <inheritdoc />
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);
}
/// <summary>
/// Implementation of <see cref="IConnectionMappedHubContext{THub, THubMethods}.OnConnectionMapGroups"/>.
/// </summary>
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> to map the groups for.</param>
/// <param name="mappingFunc">The <see cref="Func{T, TResult}"/> taking the mapped group names as an <see cref="IEnumerable{T}"/> of <see cref="string"/> resulting in a <see cref="ValueTask"/> to be <see langword="await"/>ed.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="IEnumerable{T}"/> of the <see cref="JobsHub"/> group names the user belongs in.</returns>
async ValueTask MapConnectionGroups(
IAuthenticationContext authenticationContext,
Func<IEnumerable<string>, Task> mappingFunc,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(authenticationContext);
List<long> permedInstanceIds = null;
await databaseContextFactory.UseContext(
async databaseContext =>
permedInstanceIds = await databaseContext
.InstancePermissionSets
.AsQueryable()
.Where(ips => ips.PermissionSetId == authenticationContext.PermissionSet.Id.Value)
.Select(ips => ips.Id)
.ToListAsync(cancellationToken));
await mappingFunc(
permedInstanceIds.Select(
JobsHub.HubGroupName));
jobsHubUpdater.QueueActiveJobUpdates();
}
/// <summary>
/// Refresh the <see cref="hub"/> <see cref="Hub.Groups"/> for clients associated with a given <paramref name="permissionSetId"/>.
/// </summary>
/// <param name="permissionSetId">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="PermissionSet"/> who's users need updating.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask"/> representing the running operation.</returns>
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));
});
}
}
@@ -82,7 +82,7 @@ namespace Tgstation.Server.Host.Models
}
/// <inheritdoc />
public CompileJobResponse ToApi() => new CompileJobResponse
public CompileJobResponse ToApi() => new ()
{
DirectoryName = DirectoryName,
DmeName = DmeName,
+85 -1
View File
@@ -1,6 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Api.Rights;
namespace Tgstation.Server.Host.Models
{
@@ -26,10 +31,89 @@ namespace Tgstation.Server.Host.Models
[Required]
public Instance Instance { get; set; }
/// <summary>
/// Creates a new job for registering in the <see cref="Jobs.IJobService"/>.
/// </summary>
/// <typeparam name="TRight">The <see cref="RightsType"/> of <paramref name="cancelRight"/>.</typeparam>
/// <param name="code">The value of <see cref="Api.Models.Internal.Job.JobCode"/>. <see cref="Api.Models.Internal.Job.Description"/> will be derived from this.</param>
/// <param name="startedBy">The value of <see cref="StartedBy"/>. If <see langword="null"/>, the <see cref="User.TgsSystemUserName"/> user will be used.</param>
/// <param name="instance">The <see cref="Api.Models.Instance"/> used to generate the value of <see cref="Instance"/>.</param>
/// <param name="cancelRight">The value of <see cref="Api.Models.Internal.Job.CancelRight"/>. <see cref="Api.Models.Internal.Job.CancelRightsType"/> will be derived from this.</param>
/// <returns>A new <see cref="Job"/> ready to be registered with the <see cref="Jobs.IJobService"/>.</returns>
public static Job Create<TRight>(JobCode code, User startedBy, Api.Models.Instance instance, TRight cancelRight)
where TRight : Enum
=> new (
code,
startedBy,
instance,
RightsHelper.TypeToRight<TRight>(),
(ulong)(object)cancelRight);
/// <summary>
/// Creates a new job for registering in the <see cref="Jobs.IJobService"/>.
/// </summary>
/// <param name="code">The value of <see cref="Api.Models.Internal.Job.JobCode"/>. <see cref="Api.Models.Internal.Job.Description"/> will be derived from this.</param>
/// <param name="startedBy">The value of <see cref="StartedBy"/>. If <see langword="null"/>, the <see cref="User.TgsSystemUserName"/> user will be used.</param>
/// <param name="instance">The <see cref="Api.Models.Instance"/> used to generate the value of <see cref="Instance"/>.</param>
/// <returns>A new <see cref="Job"/> ready to be registered with the <see cref="Jobs.IJobService"/>.</returns>
public static Job Create(JobCode code, User startedBy, Api.Models.Instance instance)
=> new (
code,
startedBy,
instance,
null,
null);
/// <summary>
/// Initializes a new instance of the <see cref="Job"/> class.
/// </summary>
[Obsolete("For use by EFCore only", true)]
public Job()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Job"/> class.
/// </summary>
/// <param name="id">The value of <see cref="EntityId.Id"/>.</param>
public Job(long id)
{
Id = id;
}
/// <summary>
/// Initializes a new instance of the <see cref="Job"/> class.
/// </summary>
/// <param name="code">The value of <see cref="Api.Models.Internal.Job.JobCode"/>.</param>
/// <param name="startedBy">The value of <see cref="StartedBy"/>.</param>
/// <param name="instance">The value of <see cref="Instance"/>.</param>
/// <param name="cancelRightsType">The value of <see cref="Api.Models.Internal.Job.CancelRightsType"/>.</param>
/// <param name="cancelRight">The value of <see cref="Api.Models.Internal.Job.CancelRight"/>.</param>
Job(JobCode code, User startedBy, Api.Models.Instance instance, RightsType? cancelRightsType, ulong? cancelRight)
{
StartedBy = startedBy;
ArgumentNullException.ThrowIfNull(instance);
Instance = new Instance
{
Id = instance.Id ?? throw new InvalidOperationException("Instance associated with job does not have an Id!"),
};
Description = typeof(JobCode)
.GetField(code.ToString())
.GetCustomAttributes(false)
.OfType<DescriptionAttribute>()
.First()
.Description;
JobCode = code;
CancelRight = cancelRight;
CancelRightsType = cancelRightsType;
}
/// <inheritdoc />
public JobResponse ToApi() => new ()
{
Id = Id,
JobCode = JobCode.Value,
InstanceId = Instance.Id.Value,
StartedAt = StartedAt,
StoppedAt = StoppedAt,
Cancelled = Cancelled,
@@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Models
/// </summary>
/// <param name="showUsers">If <see cref="UserGroupResponse.Users"/> should be populated.</param>
/// <returns>A new <see cref="UserGroupResponse"/>.</returns>
public UserGroupResponse ToApi(bool showUsers) => new UserGroupResponse
public UserGroupResponse ToApi(bool showUsers) => new ()
{
Id = Id,
Name = Name,
@@ -7,19 +7,22 @@ using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Security
{
/// <inheritdoc />
sealed class AuthenticationContext : IAuthenticationContext
sealed class AuthenticationContext : IAuthenticationContext, IDisposable
{
/// <inheritdoc />
public User User { get; }
public bool Valid { get; private set; }
/// <inheritdoc />
public PermissionSet PermissionSet { get; }
public User User { get; private set; }
/// <inheritdoc />
public InstancePermissionSet InstancePermissionSet { get; }
public PermissionSet PermissionSet { get; private set; }
/// <inheritdoc />
public ISystemIdentity SystemIdentity { get; }
public InstancePermissionSet InstancePermissionSet { get; private set; }
/// <inheritdoc />
public ISystemIdentity SystemIdentity { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationContext"/> class.
@@ -28,13 +31,16 @@ namespace Tgstation.Server.Host.Security
{
}
/// <inheritdoc />
public void Dispose() => SystemIdentity?.Dispose();
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationContext"/> class.
/// Initializes the <see cref="AuthenticationContext"/>.
/// </summary>
/// <param name="systemIdentity">The value of <see cref="SystemIdentity"/>.</param>
/// <param name="user">The value of <see cref="User"/>.</param>
/// <param name="instanceUser">The value of <see cref="InstancePermissionSet"/>.</param>
public AuthenticationContext(ISystemIdentity systemIdentity, User user, InstancePermissionSet instanceUser)
public void Initialize(ISystemIdentity systemIdentity, User user, InstancePermissionSet instanceUser)
{
User = user ?? throw new ArgumentNullException(nameof(user));
if (systemIdentity == null && User.SystemIdentifier != null)
@@ -44,10 +50,9 @@ namespace Tgstation.Server.Host.Security
?? throw new ArgumentException("No PermissionSet provider", nameof(user));
InstancePermissionSet = instanceUser;
SystemIdentity = systemIdentity;
}
/// <inheritdoc />
public void Dispose() => SystemIdentity?.Dispose();
Valid = true;
}
/// <inheritdoc />
public ulong GetRight(RightsType rightsType)
@@ -69,10 +74,15 @@ namespace Tgstation.Server.Host.Security
var prop = typeToCheck.GetProperties().Where(x => x.PropertyType == nullableRightsType).First();
var right = prop.GetMethod.Invoke(isInstance ? InstancePermissionSet : PermissionSet, Array.Empty<object>());
var right = prop.GetMethod.Invoke(
isInstance
? InstancePermissionSet
: PermissionSet,
Array.Empty<object>());
if (right == null)
throw new InvalidOperationException("A user right was null!");
return (ulong)right;
}
}
@@ -0,0 +1,53 @@
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// An <see cref="IAuthorizationFilter"/> that maps <see cref="Claim"/>s using an <see cref="IAuthenticationContext"/>.
/// </summary>
sealed class AuthenticationContextAuthorizationFilter : IAuthorizationFilter
{
/// <summary>
/// The <see cref="IAuthenticationContext"/> for the <see cref="AuthenticationContextAuthorizationFilter"/>.
/// </summary>
readonly IAuthenticationContext authenticationContext;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="AuthenticationContextAuthorizationFilter"/>.
/// </summary>
readonly ILogger<AuthenticationContextAuthorizationFilter> logger;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationContextAuthorizationFilter"/> class.
/// </summary>
/// <param name="authenticationContext">The value of <see cref="authenticationContext"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public AuthenticationContextAuthorizationFilter(IAuthenticationContext authenticationContext, ILogger<AuthenticationContextAuthorizationFilter> logger)
{
this.authenticationContext = authenticationContext ?? throw new ArgumentNullException(nameof(authenticationContext));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!authenticationContext.Valid)
{
logger.LogTrace("authenticationContext is invalid!");
context.Result = new UnauthorizedResult();
return;
}
if (authenticationContext.User.Enabled.Value)
return;
logger.LogTrace("authenticationContext is for a disabled user!");
context.Result = new ForbidResult();
}
}
}
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Rights;
using Tgstation.Server.Host.Utils;
namespace Tgstation.Server.Host.Security
{
/// <summary>
/// A <see cref="IClaimsTransformation"/> that maps <see cref="Claim"/>s using an <see cref="IAuthenticationContext"/>.
/// </summary>
sealed class AuthenticationContextClaimsTransformation : IClaimsTransformation
{
/// <summary>
/// The <see cref="IAuthenticationContextFactory"/> for the <see cref="AuthenticationContextClaimsTransformation"/>.
/// </summary>
readonly IAuthenticationContextFactory authenticationContextFactory;
/// <summary>
/// The <see cref="ApiHeaders"/> for the <see cref="AuthenticationContextClaimsTransformation"/>.
/// </summary>
readonly ApiHeaders apiHeaders;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationContextClaimsTransformation"/> class.
/// </summary>
/// <param name="authenticationContextFactory">The value of <see cref="authenticationContextFactory"/>.</param>
/// <param name="apiHeadersProvider">The <see cref="IApiHeadersProvider"/> containing the value of <see cref="apiHeaders"/>.</param>
public AuthenticationContextClaimsTransformation(IAuthenticationContextFactory authenticationContextFactory, IApiHeadersProvider apiHeadersProvider)
{
this.authenticationContextFactory = authenticationContextFactory ?? throw new ArgumentNullException(nameof(authenticationContextFactory));
ArgumentNullException.ThrowIfNull(apiHeadersProvider);
apiHeaders = apiHeadersProvider.ApiHeaders;
}
/// <inheritdoc />
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ArgumentNullException.ThrowIfNull(principal);
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub);
if (userIdClaim == default)
throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Sub}' claim!");
long userId;
try
{
userId = Int64.Parse(userIdClaim.Value, CultureInfo.InvariantCulture);
}
catch (Exception e)
{
throw new InvalidOperationException("Failed to parse user ID!", e);
}
var nbfClaim = principal.FindFirst(JwtRegisteredClaimNames.Nbf);
if (nbfClaim == default)
throw new InvalidOperationException($"Missing '{JwtRegisteredClaimNames.Nbf}' claim!");
DateTimeOffset nbf;
try
{
nbf = new DateTimeOffset(
EpochTime.DateTime(
Int64.Parse(nbfClaim.Value, CultureInfo.InvariantCulture)));
}
catch (Exception ex)
{
throw new InvalidOperationException("Failed to parse nbf!", ex);
}
var authenticationContext = await authenticationContextFactory.CreateAuthenticationContext(
userId,
apiHeaders?.InstanceId,
nbf,
CancellationToken.None); // DCT: None available
if (authenticationContext.Valid)
{
var enumerator = Enum.GetValues(typeof(RightsType));
var claims = new List<Claim>();
foreach (RightsType rightType in enumerator)
{
// if there's no instance user, do a weird thing and add all the instance roles
// we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid
// if user is null that means they got the token with an expired password
var rightAsULong = authenticationContext.User == null
|| (RightsHelper.IsInstanceRight(rightType) && authenticationContext.InstancePermissionSet == null)
? ~0UL
: authenticationContext.GetRight(rightType);
var rightEnum = RightsHelper.RightToType(rightType);
var right = (Enum)Enum.ToObject(rightEnum, rightAsULong);
foreach (Enum enumeratedRight in Enum.GetValues(rightEnum))
if (right.HasFlag(enumeratedRight))
claims.Add(
new Claim(
ClaimTypes.Role,
RightsHelper.RoleName(rightType, enumeratedRight)));
}
principal.AddIdentity(new ClaimsIdentity(claims));
}
return principal;
}
}
}
@@ -13,11 +13,13 @@ using Tgstation.Server.Host.Models;
namespace Tgstation.Server.Host.Security
{
/// <inheritdoc cref="IAuthenticationContextFactory" />
/// <inheritdoc cref="IAuthenticationContext" />
sealed class AuthenticationContextFactory : IAuthenticationContextFactory, IDisposable
{
/// <inheritdoc />
public IAuthenticationContext CurrentAuthenticationContext { get; private set; }
/// <summary>
/// The <see cref="IAuthenticationContext"/> the <see cref="AuthenticationContextFactory"/> created.
/// </summary>
public IAuthenticationContext CurrentAuthenticationContext => currentAuthenticationContext;
/// <summary>
/// The <see cref="IDatabaseContext"/> for the <see cref="AuthenticationContextFactory"/>.
@@ -39,6 +41,16 @@ namespace Tgstation.Server.Host.Security
/// </summary>
readonly SwarmConfiguration swarmConfiguration;
/// <summary>
/// Backing field for <see cref="CurrentAuthenticationContext"/>.
/// </summary>
readonly AuthenticationContext currentAuthenticationContext;
/// <summary>
/// 1 if <see cref="currentAuthenticationContext"/> was initialized, 0 otherwise.
/// </summary>
int initialized;
/// <summary>
/// Initializes a new instance of the <see cref="AuthenticationContextFactory"/> class.
/// </summary>
@@ -56,15 +68,17 @@ namespace Tgstation.Server.Host.Security
this.identityCache = identityCache ?? throw new ArgumentNullException(nameof(identityCache));
swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
currentAuthenticationContext = new AuthenticationContext();
}
/// <inheritdoc />
public void Dispose() => CurrentAuthenticationContext?.Dispose();
public void Dispose() => currentAuthenticationContext.Dispose();
/// <inheritdoc />
public async ValueTask CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset validAfter, CancellationToken cancellationToken)
public async ValueTask<IAuthenticationContext> CreateAuthenticationContext(long userId, long? instanceId, DateTimeOffset notBefore, CancellationToken cancellationToken)
{
if (CurrentAuthenticationContext != null)
if (Interlocked.Exchange(ref initialized, 1) != 0)
throw new InvalidOperationException("Authentication context has already been loaded");
var user = await databaseContext
@@ -79,9 +93,8 @@ namespace Tgstation.Server.Host.Security
.FirstOrDefaultAsync(cancellationToken);
if (user == default)
{
logger.LogWarning("Unable to find user with ID {0}!", userId);
CurrentAuthenticationContext = new AuthenticationContext();
return;
logger.LogWarning("Unable to find user with ID {userId}!", userId);
return currentAuthenticationContext;
}
ISystemIdentity systemIdentity;
@@ -89,11 +102,10 @@ namespace Tgstation.Server.Host.Security
systemIdentity = identityCache.LoadCachedIdentity(user);
else
{
if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > validAfter)
if (user.LastPasswordUpdate.HasValue && user.LastPasswordUpdate > notBefore)
{
logger.LogDebug("Rejecting token for user {0} created before last password update: {1}", userId, user.LastPasswordUpdate.Value);
CurrentAuthenticationContext = new AuthenticationContext();
return;
logger.LogDebug("Rejecting token for user {userId} created before last password update: {lastPasswordUpdate}", userId, user.LastPasswordUpdate.Value);
return currentAuthenticationContext;
}
systemIdentity = null;
@@ -112,13 +124,14 @@ namespace Tgstation.Server.Host.Security
.FirstOrDefaultAsync(cancellationToken);
if (instancePermissionSet == null)
logger.LogDebug("User {0} does not have permissions on instance {1}!", userId, instanceId.Value);
logger.LogDebug("User {userId} does not have permissions on instance {instanceId}!", userId, instanceId.Value);
}
CurrentAuthenticationContext = new AuthenticationContext(
currentAuthenticationContext.Initialize(
systemIdentity,
user,
instancePermissionSet);
return currentAuthenticationContext;
}
catch
{

Some files were not shown because too many files have changed in this diff Show More