diff --git a/build/Version.props b/build/Version.props index af9216cd7e..c908bf3fa5 100644 --- a/build/Version.props +++ b/build/Version.props @@ -4,8 +4,8 @@ 4.5.4 2.1.0 - 7.3.2 - 8.3.2 + 7.4.0 + 8.4.0 5.2.6 0.4.0 1.1.0 diff --git a/src/Tgstation.Server.Api/Models/ErrorCode.cs b/src/Tgstation.Server.Api/Models/ErrorCode.cs index 6c9772d582..700ac27969 100644 --- a/src/Tgstation.Server.Api/Models/ErrorCode.cs +++ b/src/Tgstation.Server.Api/Models/ErrorCode.cs @@ -570,5 +570,17 @@ namespace Tgstation.Server.Api.Models /// [Description("Failed to allow DreamDaemon through the Windows firewall!")] ByondDreamDaemonFirewallFail, + + /// + /// Attempted to create an instance but no free ports could be found. + /// + [Description("TGS was unable to find a free port to allocate for the operation!")] + NoPortsAvailable, + + /// + /// Attempted to set a port which is either in use by another part of TGS or otherwise not available for binding. + /// + [Description("The requested port is either already in use by TGS or could not be allocated!")] + PortNotAvailable, } } diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 7864d1a5ae..b67056b73e 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -12,6 +12,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Session; +using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; @@ -26,10 +27,15 @@ namespace Tgstation.Server.Host.Controllers public sealed class DreamDaemonController : InstanceRequiredController { /// - /// The for the + /// The for the . /// readonly IJobManager jobManager; + /// + /// The for the . + /// + readonly IPortAllocator portAllocator; + /// /// Construct a /// @@ -37,12 +43,14 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The value of /// The for the . + /// The value of . /// The for the public DreamDaemonController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, + IPortAllocator portAllocator, ILogger logger) : base( instanceManager, @@ -51,6 +59,7 @@ namespace Tgstation.Server.Host.Controllers logger) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); } /// @@ -219,6 +228,18 @@ namespace Tgstation.Server.Host.Controllers if (current == default) return Gone(); + if (model.Port.HasValue && model.Port.Value != current.Port.Value) + { + var verifiedPort = await portAllocator + .GetAvailablePort( + model.Port.Value, + true, + cancellationToken) + .ConfigureAwait(false); + if (verifiedPort != model.Port) + return Conflict(new ErrorMessage(ErrorCode.PortNotAvailable)); + } + var userRights = (DreamDaemonRights)AuthenticationContext.GetRight(RightsType.DreamDaemon); bool CheckModified(Expression> expression, DreamDaemonRights requiredRight) diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 7a4ec3bd0c..b1bc9b6432 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; @@ -10,6 +10,7 @@ using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; @@ -29,6 +30,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IJobManager jobManager; + /// + /// The for the . + /// + readonly IPortAllocator portAllocator; + /// /// Construct a /// @@ -36,12 +42,14 @@ namespace Tgstation.Server.Host.Controllers /// The for the /// The value of /// The for the . + /// The value of . /// The for the public DreamMakerController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, + IPortAllocator portAllocator, ILogger logger) : base( instanceManager, @@ -50,6 +58,7 @@ namespace Tgstation.Server.Host.Controllers logger) { this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); } /// @@ -202,7 +211,20 @@ namespace Tgstation.Server.Host.Controllers { if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetApiValidationPort)) return Forbid(); - hostModel.ApiValidationPort = model.ApiValidationPort; + + if (model.ApiValidationPort.Value != hostModel.ApiValidationPort.Value) + { + var verifiedPort = await portAllocator + .GetAvailablePort( + model.ApiValidationPort.Value, + true, + cancellationToken) + .ConfigureAwait(false); + if (verifiedPort != model.ApiValidationPort) + return Conflict(new ErrorMessage(ErrorCode.PortNotAvailable)); + + hostModel.ApiValidationPort = model.ApiValidationPort; + } } if (model.ApiValidationSecurityLevel.HasValue) diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 126458dba5..42d6d1a49e 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -15,6 +15,7 @@ using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Database; using Tgstation.Server.Host.IO; using Tgstation.Server.Host.Jobs; @@ -61,6 +62,11 @@ namespace Tgstation.Server.Host.Controllers /// readonly IPlatformIdentifier platformIdentifier; + /// + /// The for the . + /// + readonly IPortAllocator portAllocator; + /// /// The for the . /// @@ -75,14 +81,16 @@ namespace Tgstation.Server.Host.Controllers /// The value of /// The value of /// The value of + /// The value of . /// The containing the value of . - /// The for the + /// The for the . public InstanceController( IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, IIOManager ioManager, + IPortAllocator portAllocator, IPlatformIdentifier platformIdentifier, IOptions generalConfigurationOptions, ILogger logger) @@ -96,18 +104,43 @@ namespace Tgstation.Server.Host.Controllers this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier)); + this.portAllocator = portAllocator ?? throw new ArgumentNullException(nameof(portAllocator)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } - Models.Instance CreateDefaultInstance(Api.Models.Instance initialSettings) - => new Models.Instance + async Task CreateDefaultInstance(Api.Models.Instance initialSettings, CancellationToken cancellationToken) + { + var ddPort = await portAllocator.GetAvailablePort(1, false, cancellationToken).ConfigureAwait(false); + if (!ddPort.HasValue) + return null; + + // try to use the old default if possible + const ushort DefaultDreamDaemonPort = 1337; + if (ddPort.Value < DefaultDreamDaemonPort) + ddPort = await portAllocator.GetAvailablePort(DefaultDreamDaemonPort, false, cancellationToken).ConfigureAwait(false) ?? ddPort; + + const ushort DefaultApiValidationPort = 1339; + var dmPort = await portAllocator + .GetAvailablePort( + Math.Min((ushort)(ddPort.Value + 1), DefaultApiValidationPort), + false, + cancellationToken) + .ConfigureAwait(false); + if (!dmPort.HasValue) + return null; + + // try to use the old default if possible + if (dmPort < DefaultApiValidationPort) + dmPort = await portAllocator.GetAvailablePort(DefaultApiValidationPort, false, cancellationToken).ConfigureAwait(false) ?? dmPort; + + return new Models.Instance { ConfigurationType = initialSettings.ConfigurationType ?? ConfigurationType.Disallowed, DreamDaemonSettings = new DreamDaemonSettings { AllowWebClient = false, AutoStart = false, - Port = 1337, + Port = ddPort, SecurityLevel = DreamDaemonSecurity.Safe, StartupTimeout = 60, HeartbeatSeconds = 60, @@ -115,7 +148,7 @@ namespace Tgstation.Server.Host.Controllers }, DreamMakerSettings = new DreamMakerSettings { - ApiValidationPort = 1339, + ApiValidationPort = dmPort, ApiValidationSecurityLevel = DreamDaemonSecurity.Safe, RequireDMApiValidation = true }, @@ -140,6 +173,7 @@ namespace Tgstation.Server.Host.Controllers InstanceAdminUser(null) } }; + } string NormalizePath(string path) { @@ -277,7 +311,9 @@ namespace Tgstation.Server.Host.Controllers else attached = true; - var newInstance = CreateDefaultInstance(model); + var newInstance = await CreateDefaultInstance(model, cancellationToken).ConfigureAwait(false); + if (newInstance == null) + return Conflict(new ErrorMessage(ErrorCode.NoPortsAvailable)); DatabaseContext.Instances.Add(newInstance); try diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0048f302d7..b1dc85fcee 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -294,6 +294,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddScoped(); // configure component services services.AddSingleton(); diff --git a/src/Tgstation.Server.Host/Core/IPortAllocator.cs b/src/Tgstation.Server.Host/Core/IPortAllocator.cs new file mode 100644 index 0000000000..4ab7c3b09c --- /dev/null +++ b/src/Tgstation.Server.Host/Core/IPortAllocator.cs @@ -0,0 +1,20 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Core +{ + /// + /// Gets unassigned ports for use by TGS. + /// + public interface IPortAllocator + { + /// + /// Gets a port not currently in use by TGS. + /// + /// The port to check first. Will not allocate a port lower than this. + /// If only should be checked and no others. + /// The for the operation. + /// A resulting in the first available port on success, on failure. + Task GetAvailablePort(ushort basePort, bool checkOne, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Core/PortAllocator.cs b/src/Tgstation.Server.Host/Core/PortAllocator.cs new file mode 100644 index 0000000000..81b1a57eb6 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/PortAllocator.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Extensions; + +namespace Tgstation.Server.Host.Core +{ + /// + sealed class PortAllocator : IPortAllocator + { + /// + /// The for the . + /// + readonly IServerPortProvider serverPortProvider; + + /// + /// The for the . + /// + readonly IDatabaseContext databaseContext; + + /// + /// The for the . + /// + readonly ILogger logger; + + /// + /// Initializes a new instance of the . + /// + /// The value of . + /// The value of . + /// The value of . + public PortAllocator(IServerPortProvider serverPortProvider, IDatabaseContext databaseContext, ILogger logger) + { + this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider)); + this.databaseContext = databaseContext ?? throw new ArgumentNullException(nameof(databaseContext)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task GetAvailablePort(ushort basePort, bool checkOne, CancellationToken cancellationToken) + { + logger.LogTrace("Port allocation >= {0} requested...", basePort); + + var ddPorts = await databaseContext + .DreamDaemonSettings + .AsQueryable() + .Select(x => x.Port) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var dmPorts = await databaseContext + .DreamMakerSettings + .AsQueryable() + .Select(x => x.ApiValidationPort) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + for (var I = basePort; I < UInt16.MaxValue; ++I) + { + if (checkOne && I != basePort) + break; + + if (I == serverPortProvider.HttpApiPort + || ddPorts.Contains(I) + || dmPorts.Contains(I)) + continue; + + try + { + logger.LogTrace("Bind test: {0}", I); + SocketExtensions.BindTest(I, false); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Not using port {0}", I); + } + + logger.LogInformation("Allocated port {0}", I); + return I; + } + + logger.LogWarning("Unable to allocate port >= {0}!", basePort); + return null; + } + } +} diff --git a/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs b/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs index 578dffa9a5..3bf72f06e1 100644 --- a/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs +++ b/tests/Tgstation.Server.Tests/Instance/DeploymentTest.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Tgstation.Server.Api.Models; +using Tgstation.Server.Client; using Tgstation.Server.Client.Components; using Tgstation.Server.Host.System; @@ -34,17 +35,42 @@ namespace Tgstation.Server.Tests.Instance // by alphabetization rules, it should discover api_free here if (!new PlatformIdentifier().IsWindows) - await dreamMakerClient.Update(new DreamMaker + { + var updatedDM = await dreamMakerClient.Update(new DreamMaker { ProjectName = "tests/DMAPI/ApiFree/api_free", ApiValidationPort = IntegrationTest.DMPort }, cancellationToken); + Assert.AreEqual(IntegrationTest.DMPort, updatedDM.ApiValidationPort); + Assert.AreEqual("tests/DMAPI/ApiFree/api_free", updatedDM.ProjectName); + } + else + { + var updatedDM = await dreamMakerClient.Update(new DreamMaker + { + ApiValidationPort = IntegrationTest.DMPort + }, cancellationToken); + Assert.AreEqual(IntegrationTest.DMPort, updatedDM.ApiValidationPort); + } var updatedDD = await dreamDaemonClient.Update(new DreamDaemon { - StartupTimeout = 5 + StartupTimeout = 5, + Port = IntegrationTest.DDPort }, cancellationToken); Assert.AreEqual(5U, updatedDD.StartupTimeout); + Assert.AreEqual(IntegrationTest.DDPort, updatedDD.Port); + + await ApiAssert.ThrowsException(() => dreamDaemonClient.Update(new DreamDaemon + { + Port = IntegrationTest.DMPort + }, cancellationToken), ErrorCode.PortNotAvailable); + + await ApiAssert.ThrowsException(() => dreamMakerClient.Update(new DreamMaker + { + ApiValidationPort = IntegrationTest.DDPort + }, cancellationToken), ErrorCode.PortNotAvailable); + deployJob = await dreamMakerClient.Compile(cancellationToken); await WaitForJob(deployJob, 30, true, ErrorCode.DreamMakerNeverValidated, cancellationToken);