diff --git a/build/Version.props b/build/Version.props index d0a850c6de..9c784ed4d7 100644 --- a/build/Version.props +++ b/build/Version.props @@ -12,6 +12,7 @@ 7.3.0 5.10.0 1.5.0 + 7.0.0 1.2.1 2.0.0 netstandard2.0 diff --git a/src/Tgstation.Server.Host/Controllers/SwarmController.cs b/src/Tgstation.Server.Host/Controllers/SwarmController.cs index be171b19d8..dd57e45138 100644 --- a/src/Tgstation.Server.Host/Controllers/SwarmController.cs +++ b/src/Tgstation.Server.Host/Controllers/SwarmController.cs @@ -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 /// readonly IFileTransferStreamHandler transferService; - /// - /// The for the . - /// - readonly IAssemblyInformationProvider assemblyInformationProvider; - /// /// The for the . /// @@ -63,19 +58,16 @@ namespace Tgstation.Server.Host.Controllers /// Initializes a new instance of the class. /// /// The value of . - /// The value of . /// The value of . /// The containing the value of . /// The value of . public SwarmController( ISwarmOperations swarmOperations, - IAssemblyInformationProvider assemblyInformationProvider, IFileTransferStreamHandler transferService, IOptions swarmConfigurationOptions, ILogger 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); } /// diff --git a/src/Tgstation.Server.Host/Database/DatabaseContext.cs b/src/Tgstation.Server.Host/Database/DatabaseContext.cs index dbf724e45d..d79bb709f9 100644 --- a/src/Tgstation.Server.Host/Database/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Database/DatabaseContext.cs @@ -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 /// @@ -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 { diff --git a/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs b/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs index d77bcd349b..8212c4ffee 100644 --- a/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs +++ b/src/Tgstation.Server.Host/Properties/MasterVersionsAttribute.cs @@ -41,6 +41,11 @@ namespace Tgstation.Server.Host.Properties /// public string RawMariaDBRedistVersion { get; } + /// + /// The of the MariaDB server bundled with TGS installs. + /// + public string RawSwarmProtocolVersion { get; } + /// /// Initializes a new instance of the class. /// @@ -49,18 +54,21 @@ namespace Tgstation.Server.Host.Properties /// The value of . /// The value of . /// The value of . + /// The value of . 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)); } } } diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index bf9a70fab1..707c5d78ff 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -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 /// public interface ITokenFactory { + /// + /// Gets or sets the 's signing key s. + /// + ReadOnlySpan SigningKeyBytes { get; set; } + /// /// The for the . /// diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index fcc5990c2f..d2ab3c0527 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -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 /// public TokenValidationParameters ValidationParameters { get; } + /// + public ReadOnlySpan SigningKeyBytes + { + get => signingKey.Key; + [MemberNotNull(nameof(signingKey))] + [MemberNotNull(nameof(tokenHeader))] + set + { + signingKey = new SymmetricSecurityKey(value.ToArray()); + tokenHeader = new JwtHeader( + new SigningCredentials( + signingKey, + SecurityAlgorithms.HmacSha256)); + } + } + /// /// The for the . /// readonly SecurityConfiguration securityConfiguration; - /// - /// The for generating tokens. - /// - readonly JwtHeader tokenHeader; - /// /// The used to generate s. /// readonly JwtSecurityTokenHandler tokenHandler; + /// + /// Backing field for . + /// + SymmetricSecurityKey signingKey; + + /// + /// The for generating tokens. + /// + JwtHeader tokenHeader; + /// /// Initializes a new instance of the class. /// @@ -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(); } diff --git a/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs b/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs index 5a0889b7e9..c8ae31031a 100644 --- a/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs +++ b/src/Tgstation.Server.Host/Swarm/ISwarmOperations.cs @@ -39,8 +39,8 @@ namespace Tgstation.Server.Host.Swarm /// The that is registering. /// The registration . /// The for the operation. - /// A resulting in if the registration was successful, otherwise. - ValueTask RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken); + /// A resulting in a if the registration was successful, otherwise. + ValueTask RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken); /// /// Attempt to unregister a node with a given with the controller. diff --git a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs index 229d4d28d2..f6bda1ac47 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationRequest.cs @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.Swarm public sealed class SwarmRegistrationRequest : SwarmServer { /// - /// The TGS of the sending server. + /// The swarm protocol of the sending server. Named this way due to legacy reasons. /// [Required] public Version ServerVersion { get; } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResponse.cs b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResponse.cs new file mode 100644 index 0000000000..a31858fa29 --- /dev/null +++ b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResponse.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Host.Swarm +{ + /// + /// Response for a . + /// + public sealed class SwarmRegistrationResponse + { + /// + /// The base64 encoded token signing key. + /// + public required string TokenSigningKeyBase64 { get; init; } + } +} diff --git a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs index 87508af508..a754aa52cd 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmRegistrationResult.cs @@ -24,5 +24,10 @@ /// A communication error occurred. /// CommunicationFailure, + + /// + /// Response could not be deserialized. + /// + PayloadFailure, } } diff --git a/src/Tgstation.Server.Host/Swarm/SwarmService.cs b/src/Tgstation.Server.Host/Swarm/SwarmService.cs index ea77de6a3c..9884ac7193 100644 --- a/src/Tgstation.Server.Host/Swarm/SwarmService.cs +++ b/src/Tgstation.Server.Host/Swarm/SwarmService.cs @@ -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 /// /// Helps keep servers connected to the same database in sync by coordinating updates. /// +#pragma warning disable CA1506 // TODO: Decomplexify sealed class SwarmService : ISwarmService, ISwarmServiceController, ISwarmOperations, IDisposable +#pragma warning restore CA1506 { /// public bool ExpectedNumberOfNodesConnected @@ -89,6 +93,11 @@ namespace Tgstation.Server.Host.Swarm /// readonly IFileTransferTicketProvider transferService; + /// + /// The for the . + /// + readonly ITokenFactory tokenFactory; + /// /// The for the . /// @@ -159,6 +168,7 @@ namespace Tgstation.Server.Host.Swarm /// The value of . /// The value of . /// The value of . + /// The value of . /// The containing the value of . /// The value of . public SwarmService( @@ -169,6 +179,7 @@ namespace Tgstation.Server.Host.Swarm IAsyncDelayer asyncDelayer, IServerUpdater serverUpdater, IFileTransferTicketProvider transferService, + ITokenFactory tokenFactory, IOptions swarmConfigurationOptions, ILogger 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 } /// - public async ValueTask RegisterNode(SwarmServer node, Guid registrationId, CancellationToken cancellationToken) + public async ValueTask 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(); } /// @@ -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(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; diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 5510316328..8317ed2e64 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -56,6 +56,7 @@ <_Parameter3>$(TgsWebpanelVersion) <_Parameter4>$(TgsHostWatchdogVersion) <_Parameter5>$(TgsMariaDBRedistVersion) + <_Parameter6>$(TgsSwarmProtocolVersion) diff --git a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs index c293318381..2fbc888b1b 100644 --- a/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.cs +++ b/tests/Tgstation.Server.Host.Tests/Swarm/TestableSwarmNode.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 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()), @@ -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); } diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index c0063a1a1e..bc55b33077 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -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(() => 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(