Merge branch 'PrivateKeySharing' into MoreGraphQL

This commit is contained in:
Jordan Dominion
2024-09-13 00:02:44 -04:00
14 changed files with 157 additions and 36 deletions
+1
View File
@@ -12,6 +12,7 @@
<TgsDmapiVersion>7.3.0</TgsDmapiVersion>
<TgsInteropVersion>5.10.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.5.0</TgsHostWatchdogVersion>
<TgsSwarmProtocolVersion>7.0.0</TgsSwarmProtocolVersion>
<TgsContainerScriptVersion>1.2.1</TgsContainerScriptVersion>
<TgsMigratorVersion>2.0.0</TgsMigratorVersion>
<TgsNugetNetFramework>netstandard2.0</TgsNugetNetFramework>
@@ -14,8 +14,8 @@ using Serilog.Context;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.Properties;
using Tgstation.Server.Host.Swarm;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils;
@@ -44,11 +44,6 @@ namespace Tgstation.Server.Host.Controllers
/// </summary>
readonly IFileTransferStreamHandler transferService;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="SwarmController"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="SwarmController"/>.
/// </summary>
@@ -63,19 +58,16 @@ namespace Tgstation.Server.Host.Controllers
/// Initializes a new instance of the <see cref="SwarmController"/> class.
/// </summary>
/// <param name="swarmOperations">The value of <see cref="swarmOperations"/>.</param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="transferService">The value of <see cref="transferService"/>.</param>
/// <param name="swarmConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="swarmConfiguration"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public SwarmController(
ISwarmOperations swarmOperations,
IAssemblyInformationProvider assemblyInformationProvider,
IFileTransferStreamHandler transferService,
IOptions<SwarmConfiguration> swarmConfigurationOptions,
ILogger<SwarmController> logger)
{
this.swarmOperations = swarmOperations ?? throw new ArgumentNullException(nameof(swarmOperations));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.transferService = transferService ?? throw new ArgumentNullException(nameof(transferService));
swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
this.logger = logger;
@@ -92,13 +84,18 @@ namespace Tgstation.Server.Host.Controllers
{
ArgumentNullException.ThrowIfNull(registrationRequest);
if (registrationRequest.ServerVersion != assemblyInformationProvider.Version)
var swarmProtocolVersion = Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion);
if (registrationRequest.ServerVersion?.Major != swarmProtocolVersion.Major)
return StatusCode((int)HttpStatusCode.UpgradeRequired);
var registrationResult = await swarmOperations.RegisterNode(registrationRequest, RequestRegistrationId, cancellationToken);
if (!registrationResult)
if (registrationResult == null)
return Conflict();
return NoContent();
if (registrationRequest.ServerVersion != swarmProtocolVersion)
logger.LogWarning("Allowed node {identifier} to register despite having a slightly different swarm protocol version!", registrationRequest.Identifier);
return Json(registrationResult);
}
/// <summary>
@@ -445,6 +445,7 @@ namespace Tgstation.Server.Host.Database
// HEY YOU
// IF YOU HAVE A TEST THAT'S CREATING ERRORS BECAUSE THESE VALUES AREN'T SET CORRECTLY THERE'S MORE TO FIXING IT THAN JUST UPDATING THEM
// IN THE FUNCTION BELOW YOU ALSO NEED TO CORRECTLY SET THE RIGHT MIGRATION TO DOWNGRADE TO FOR THE LAST TGS VERSION
// YOU ALSO NEED TO UPDATE THE SWARM PROTOCOL MAJOR VERSION
// IF THIS BREAKS AGAIN I WILL PERSONALLY HAUNT YOUR ASS WHEN I DIE
/// <summary>
@@ -480,6 +481,7 @@ namespace Tgstation.Server.Host.Database
string BadDatabaseType() => throw new ArgumentException($"Invalid DatabaseType: {currentDatabaseType}", nameof(currentDatabaseType));
// !!! DON'T FORGET TO UPDATE THE SWARM PROTOCOL MAJOR VERSION !!!
if (targetVersion < new Version(6, 7, 0))
targetMigration = currentDatabaseType switch
{
@@ -41,6 +41,11 @@ namespace Tgstation.Server.Host.Properties
/// </summary>
public string RawMariaDBRedistVersion { get; }
/// <summary>
/// The <see cref="Version"/> <see cref="string"/> of the MariaDB server bundled with TGS installs.
/// </summary>
public string RawSwarmProtocolVersion { get; }
/// <summary>
/// Initializes a new instance of the <see cref="MasterVersionsAttribute"/> class.
/// </summary>
@@ -49,18 +54,21 @@ namespace Tgstation.Server.Host.Properties
/// <param name="rawWebpanelVersion">The value of <see cref="RawWebpanelVersion"/>.</param>
/// <param name="rawHostWatchdogVersion">The value of <see cref="RawHostWatchdogVersion"/>.</param>
/// <param name="rawMariaDBRedistVersion">The value of <see cref="RawMariaDBRedistVersion"/>.</param>
/// <param name="rawSwarmProtocolVersion">The value of <see cref="RawSwarmProtocolVersion"/>.</param>
public MasterVersionsAttribute(
string rawConfigurationVersion,
string rawInteropVersion,
string rawWebpanelVersion,
string rawHostWatchdogVersion,
string rawMariaDBRedistVersion)
string rawMariaDBRedistVersion,
string rawSwarmProtocolVersion)
{
RawConfigurationVersion = rawConfigurationVersion ?? throw new ArgumentNullException(nameof(rawConfigurationVersion));
RawInteropVersion = rawInteropVersion ?? throw new ArgumentNullException(nameof(rawInteropVersion));
RawWebpanelVersion = rawWebpanelVersion ?? throw new ArgumentNullException(nameof(rawWebpanelVersion));
RawHostWatchdogVersion = rawHostWatchdogVersion ?? throw new ArgumentNullException(nameof(rawHostWatchdogVersion));
RawMariaDBRedistVersion = rawMariaDBRedistVersion ?? throw new ArgumentNullException(nameof(rawMariaDBRedistVersion));
RawSwarmProtocolVersion = rawSwarmProtocolVersion ?? throw new ArgumentNullException(nameof(rawSwarmProtocolVersion));
}
}
}
@@ -1,4 +1,6 @@
using Microsoft.IdentityModel.Tokens;
using System;
using Microsoft.IdentityModel.Tokens;
using Tgstation.Server.Api.Models.Response;
@@ -9,6 +11,11 @@ namespace Tgstation.Server.Host.Security
/// </summary>
public interface ITokenFactory
{
/// <summary>
/// Gets or sets the <see cref="ITokenFactory"/>'s signing key <see cref="byte"/>s.
/// </summary>
ReadOnlySpan<byte> SigningKeyBytes { get; set; }
/// <summary>
/// The <see cref="TokenValidationParameters"/> for the <see cref="ITokenFactory"/>.
/// </summary>
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
@@ -21,21 +22,42 @@ namespace Tgstation.Server.Host.Security
/// <inheritdoc />
public TokenValidationParameters ValidationParameters { get; }
/// <inheritdoc />
public ReadOnlySpan<byte> SigningKeyBytes
{
get => signingKey.Key;
[MemberNotNull(nameof(signingKey))]
[MemberNotNull(nameof(tokenHeader))]
set
{
signingKey = new SymmetricSecurityKey(value.ToArray());
tokenHeader = new JwtHeader(
new SigningCredentials(
signingKey,
SecurityAlgorithms.HmacSha256));
}
}
/// <summary>
/// The <see cref="SecurityConfiguration"/> for the <see cref="TokenFactory"/>.
/// </summary>
readonly SecurityConfiguration securityConfiguration;
/// <summary>
/// The <see cref="JwtHeader"/> for generating tokens.
/// </summary>
readonly JwtHeader tokenHeader;
/// <summary>
/// The <see cref="JwtSecurityTokenHandler"/> used to generate <see cref="TokenResponse.Bearer"/> <see cref="string"/>s.
/// </summary>
readonly JwtSecurityTokenHandler tokenHandler;
/// <summary>
/// Backing field for <see cref="SigningKeyBytes"/>.
/// </summary>
SymmetricSecurityKey signingKey;
/// <summary>
/// The <see cref="JwtHeader"/> for generating tokens.
/// </summary>
JwtHeader tokenHeader;
/// <summary>
/// Initializes a new instance of the <see cref="TokenFactory"/> class.
/// </summary>
@@ -52,14 +74,14 @@ namespace Tgstation.Server.Host.Security
securityConfiguration = securityConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(securityConfigurationOptions));
var signingKeyBytes = String.IsNullOrWhiteSpace(securityConfiguration.CustomTokenSigningKeyBase64)
SigningKeyBytes = String.IsNullOrWhiteSpace(securityConfiguration.CustomTokenSigningKeyBase64)
? cryptographySuite.GetSecureBytes(securityConfiguration.TokenSigningKeyByteCount)
: Convert.FromBase64String(securityConfiguration.CustomTokenSigningKeyBase64);
ValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(signingKeyBytes),
IssuerSigningKeyResolver = (_, _, _, _) => Enumerable.Repeat(signingKey, 1),
ValidateIssuer = true,
ValidIssuer = assemblyInformationProvider.AssemblyName.Name,
@@ -75,10 +97,6 @@ namespace Tgstation.Server.Host.Security
RequireExpirationTime = true,
};
tokenHeader = new JwtHeader(
new SigningCredentials(
ValidationParameters.IssuerSigningKey,
SecurityAlgorithms.HmacSha256));
tokenHandler = new JwtSecurityTokenHandler();
}
@@ -39,8 +39,8 @@ namespace Tgstation.Server.Host.Swarm
/// <param name="node">The <see cref="SwarmServer"/> that is registering.</param>
/// <param name="registrationId">The registration <see cref="Guid"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in <see langword="true"/> if the registration was successful, <see langword="false"/> otherwise.</returns>
ValueTask<bool> RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken);
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="SwarmRegistrationResponse"/> if the registration was successful, <see langword="null"/> otherwise.</returns>
ValueTask<SwarmRegistrationResponse?> RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken);
/// <summary>
/// Attempt to unregister a node with a given <paramref name="registrationId"/> with the controller.
@@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.Swarm
public sealed class SwarmRegistrationRequest : SwarmServer
{
/// <summary>
/// The TGS <see cref="Version"/> of the sending server.
/// The swarm protocol <see cref="Version"/> of the sending server. Named this way due to legacy reasons.
/// </summary>
[Required]
public Version ServerVersion { get; }
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Host.Swarm
{
/// <summary>
/// Response for a <see cref="SwarmRegistrationRequest"/>.
/// </summary>
public sealed class SwarmRegistrationResponse
{
/// <summary>
/// The base64 encoded token signing key.
/// </summary>
public required string TokenSigningKeyBase64 { get; init; }
}
}
@@ -24,5 +24,10 @@
/// A communication error occurred.
/// </summary>
CommunicationFailure,
/// <summary>
/// Response could not be deserialized.
/// </summary>
PayloadFailure,
}
}
@@ -24,6 +24,8 @@ using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Properties;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Transfer;
using Tgstation.Server.Host.Utils;
@@ -33,7 +35,9 @@ namespace Tgstation.Server.Host.Swarm
/// <summary>
/// Helps keep servers connected to the same database in sync by coordinating updates.
/// </summary>
#pragma warning disable CA1506 // TODO: Decomplexify
sealed class SwarmService : ISwarmService, ISwarmServiceController, ISwarmOperations, IDisposable
#pragma warning restore CA1506
{
/// <inheritdoc />
public bool ExpectedNumberOfNodesConnected
@@ -89,6 +93,11 @@ namespace Tgstation.Server.Host.Swarm
/// </summary>
readonly IFileTransferTicketProvider transferService;
/// <summary>
/// The <see cref="ITokenFactory"/> for the <see cref="SwarmService"/>.
/// </summary>
readonly ITokenFactory tokenFactory;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="SwarmService"/>.
/// </summary>
@@ -159,6 +168,7 @@ namespace Tgstation.Server.Host.Swarm
/// <param name="serverUpdater">The value of <see cref="serverUpdater"/>.</param>
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/>.</param>
/// <param name="transferService">The value of <see cref="transferService"/>.</param>
/// <param name="tokenFactory">The value of <see cref="tokenFactory"/>.</param>
/// <param name="swarmConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="swarmConfiguration"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public SwarmService(
@@ -169,6 +179,7 @@ namespace Tgstation.Server.Host.Swarm
IAsyncDelayer asyncDelayer,
IServerUpdater serverUpdater,
IFileTransferTicketProvider transferService,
ITokenFactory tokenFactory,
IOptions<SwarmConfiguration> swarmConfigurationOptions,
ILogger<SwarmService> logger)
{
@@ -179,6 +190,7 @@ namespace Tgstation.Server.Host.Swarm
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater));
this.transferService = transferService ?? throw new ArgumentNullException(nameof(transferService));
this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory));
swarmConfiguration = swarmConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(swarmConfigurationOptions));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -540,7 +552,7 @@ namespace Tgstation.Server.Host.Swarm
}
/// <inheritdoc />
public async ValueTask<bool> RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken)
public async ValueTask<SwarmRegistrationResponse?> RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(node);
@@ -560,6 +572,11 @@ namespace Tgstation.Server.Host.Swarm
await AbortUpdate();
SwarmRegistrationResponse CreateResponse() => new()
{
TokenSigningKeyBase64 = Convert.ToBase64String(tokenFactory.SigningKeyBytes),
};
var registrationIdsAndTimes = this.registrationIdsAndTimes!;
lock (swarmServers)
{
@@ -569,7 +586,7 @@ namespace Tgstation.Server.Host.Swarm
if (preExistingRegistrationKvp.Key == node.Identifier)
{
logger.LogWarning("Node {nodeId} has already registered!", node.Identifier);
return true;
return CreateResponse();
}
logger.LogWarning(
@@ -577,7 +594,7 @@ namespace Tgstation.Server.Host.Swarm
node.Identifier,
preExistingRegistrationKvp.Key,
registrationId);
return false;
return null;
}
if (registrationIdsAndTimes.TryGetValue(node.Identifier, out var oldRegistration))
@@ -599,7 +616,7 @@ namespace Tgstation.Server.Host.Swarm
logger.LogInformation("Registered node {nodeId} ({nodeIP}) with ID {registrationId}", node.Identifier, node.Address, registrationId);
MarkServersDirty();
return true;
return CreateResponse();
}
/// <inheritdoc />
@@ -1268,7 +1285,7 @@ namespace Tgstation.Server.Host.Swarm
null,
HttpMethod.Post,
SwarmConstants.RegisterRoute,
new SwarmRegistrationRequest(assemblyInformationProvider.Version)
new SwarmRegistrationRequest(Version.Parse(MasterVersionsAttribute.Instance.RawSwarmProtocolVersion))
{
Identifier = swarmConfiguration.Identifier,
Address = swarmConfiguration.Address,
@@ -1281,6 +1298,36 @@ namespace Tgstation.Server.Host.Swarm
using var response = await httpClient.SendAsync(registrationRequest, HttpCompletionOption.ResponseContentRead, cancellationToken);
if (response.IsSuccessStatusCode)
{
try
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
if (json == null)
{
logger.LogDebug("Error reading registration response content stream! Text was null!");
return SwarmRegistrationResult.PayloadFailure;
}
var registrationResponse = JsonConvert.DeserializeObject<SwarmRegistrationResponse>(json);
if (registrationResponse == null)
{
logger.LogDebug("Error reading registration response content stream! Payload was null!");
return SwarmRegistrationResult.PayloadFailure;
}
if (registrationResponse.TokenSigningKeyBase64 == null)
{
logger.LogDebug("Error reading registration response content stream! SigningKey was null!");
return SwarmRegistrationResult.PayloadFailure;
}
tokenFactory.SigningKeyBytes = Convert.FromBase64String(registrationResponse.TokenSigningKeyBase64);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Error reading registration response content stream!");
return SwarmRegistrationResult.PayloadFailure;
}
logger.LogInformation("Sucessfully registered with ID {registrationId}", requestedRegistrationId);
controllerRegistration = requestedRegistrationId;
lastControllerHealthCheck = DateTimeOffset.UtcNow;
@@ -56,6 +56,7 @@
<_Parameter3>$(TgsWebpanelVersion)</_Parameter3>
<_Parameter4>$(TgsHostWatchdogVersion)</_Parameter4>
<_Parameter5>$(TgsMariaDBRedistVersion)</_Parameter5>
<_Parameter6>$(TgsSwarmProtocolVersion)</_Parameter6>
</MasterVersionAssemblyAttributes>
</ItemGroup>
<WriteCodeFragment AssemblyAttributes="@(MasterVersionAssemblyAttributes)" Language="C#" OutputDirectory="$(IntermediateOutputPath)" OutputFile="MasterVersionsAssemblyInfo.cs">
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -20,6 +21,7 @@ using Tgstation.Server.Host.Controllers;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Database;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.System;
using Tgstation.Server.Host.Transfer;
@@ -75,6 +77,24 @@ namespace Tgstation.Server.Host.Swarm.Tests
}
}
private class MockTokenFactory : ITokenFactory
{
public ReadOnlySpan<byte> SigningKeyBytes
{
get => [0, 1, 2, 3, 4];
set
{
}
}
public TokenValidationParameters ValidationParameters => throw new NotSupportedException();
public TokenResponse CreateToken(User user, bool oAuth)
{
throw new NotSupportedException();
}
}
public TestableSwarmNode(
ILoggerFactory loggerFactory,
SwarmConfiguration swarmConfiguration,
@@ -138,7 +158,6 @@ namespace Tgstation.Server.Host.Swarm.Tests
RpcMapper = new SwarmRpcMapper(
(targetService, targetTransfer) => new SwarmController(
targetService,
mockAssemblyInformationProvider.Object,
targetTransfer,
mockOptions.Object,
loggerFactory.CreateLogger<SwarmController>()),
@@ -154,6 +173,8 @@ namespace Tgstation.Server.Host.Swarm.Tests
logger = loggerFactory.CreateLogger($"TestableSwarmNode-{swarmConfiguration.Identifier}");
var mockTokenFactory = new MockTokenFactory();
var runCount = 0;
void RecreateControllerAndService()
{
@@ -180,6 +201,7 @@ namespace Tgstation.Server.Host.Swarm.Tests
mockAsyncDelayer.Object,
mockServerUpdater.Object,
TransferService,
mockTokenFactory,
mockOptions.Object,
serviceLogger);
}
@@ -670,8 +670,8 @@ namespace Tgstation.Server.Tests.Live
newUser.Name,
"asdfasdfasdfasdf");
await using var node1BadClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
await ApiAssert.ThrowsException<UnauthorizedException, AdministrationResponse>(() => node1BadClient.Administration.Read(false, cancellationToken));
await using var node1TokenCopiedClient = clientFactory.CreateFromToken(node1.RootUrl, controllerUserClient.Token);
await node1TokenCopiedClient.Administration.Read(false, cancellationToken);
// check instance info is not shared
var controllerInstance = await controllerClient.Instances.CreateOrAttach(