Files
tgstation-server/src/Tgstation.Server.Host/Components/Session/SessionControllerFactory.cs
T
2020-07-25 12:59:47 -04:00

507 lines
18 KiB
C#

using Microsoft.Extensions.Logging;
using System;
using System.Globalization;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Byond;
using Tgstation.Server.Host.Components.Chat;
using Tgstation.Server.Host.Components.Deployment;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Components.Interop.Bridge;
using Tgstation.Server.Host.Core;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Security;
using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Components.Session
{
/// <inheritdoc />
sealed class SessionControllerFactory : ISessionControllerFactory
{
/// <summary>
/// The <see cref="IProcessExecutor"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly IProcessExecutor processExecutor;
/// <summary>
/// The <see cref="IByondManager"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly IByondManager byond;
/// <summary>
/// The <see cref="ITopicClientFactory"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly ITopicClientFactory topicClientFactory;
/// <summary>
/// The <see cref="ICryptographySuite"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly ICryptographySuite cryptographySuite;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// The <see cref="IChatManager"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly IChatManager chat;
/// <summary>
/// The <see cref="INetworkPromptReaper"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly INetworkPromptReaper networkPromptReaper;
/// <summary>
/// The <see cref="IPlatformIdentifier"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly IPlatformIdentifier platformIdentifier;
/// <summary>
/// The <see cref="IBridgeRegistrar"/> for the <see cref="SessionControllerFactory"/>.
/// </summary>
readonly IBridgeRegistrar bridgeRegistrar;
/// <summary>
/// The <see cref="IServerPortProvider"/> for the <see cref="SessionControllerFactory"/>.
/// </summary>
readonly IServerPortProvider serverPortProvider;
/// <summary>
/// The <see cref="ILoggerFactory"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly ILoggerFactory loggerFactory;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly ILogger<SessionControllerFactory> logger;
/// <summary>
/// The <see cref="Api.Models.Instance"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly Api.Models.Instance instance;
/// <summary>
/// Change a given <paramref name="securityLevel"/> into the appropriate DreamDaemon command line word
/// </summary>
/// <param name="securityLevel">The <see cref="DreamDaemonSecurity"/> level to change</param>
/// <returns>A <see cref="string"/> representation of the command line parameter</returns>
static string SecurityWord(DreamDaemonSecurity securityLevel)
{
return securityLevel switch
{
DreamDaemonSecurity.Safe => "safe",
DreamDaemonSecurity.Trusted => "trusted",
DreamDaemonSecurity.Ultrasafe => "ultrasafe",
_ => throw new ArgumentOutOfRangeException(nameof(securityLevel), securityLevel, String.Format(CultureInfo.InvariantCulture, "Bad DreamDaemon security level: {0}", securityLevel)),
};
}
/// <summary>
/// Check if a given <paramref name="port"/> can be bound to.
/// </summary>
/// <param name="port">The port number to test.</param>
void PortBindTest(ushort port)
{
try
{
logger.LogTrace("Bind test: {0}", port);
SocketExtensions.BindTest(port, false);
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse)
{
throw new JobException(ErrorCode.DreamDaemonPortInUse, ex);
}
}
/// <summary>
/// Construct a <see cref="SessionControllerFactory"/>
/// </summary>
/// <param name="processExecutor">The value of <see cref="processExecutor"/></param>
/// <param name="byond">The value of <see cref="byond"/></param>
/// <param name="topicClientFactory">The value of <see cref="topicClientFactory"/>.</param>
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/></param>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/></param>
/// <param name="instance">The value of <see cref="instance"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="networkPromptReaper">The value of <see cref="networkPromptReaper"/></param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/></param>
/// <param name="bridgeRegistrar">The value of <see cref="bridgeRegistrar"/>.</param>
/// <param name="serverPortProvider">The value of <see cref="serverPortProvider"/>.</param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
public SessionControllerFactory(
IProcessExecutor processExecutor,
IByondManager byond,
ITopicClientFactory topicClientFactory,
ICryptographySuite cryptographySuite,
IAssemblyInformationProvider assemblyInformationProvider,
IIOManager ioManager,
IChatManager chat,
INetworkPromptReaper networkPromptReaper,
IPlatformIdentifier platformIdentifier,
IBridgeRegistrar bridgeRegistrar,
IServerPortProvider serverPortProvider,
ILoggerFactory loggerFactory,
ILogger<SessionControllerFactory> logger,
Api.Models.Instance instance)
{
this.processExecutor = processExecutor ?? throw new ArgumentNullException(nameof(processExecutor));
this.byond = byond ?? throw new ArgumentNullException(nameof(byond));
this.topicClientFactory = topicClientFactory ?? throw new ArgumentNullException(nameof(topicClientFactory));
this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.instance = instance ?? throw new ArgumentNullException(nameof(instance));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
this.networkPromptReaper = networkPromptReaper ?? throw new ArgumentNullException(nameof(networkPromptReaper));
this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
this.bridgeRegistrar = bridgeRegistrar ?? throw new ArgumentNullException(nameof(bridgeRegistrar));
this.serverPortProvider = serverPortProvider ?? throw new ArgumentNullException(nameof(serverPortProvider));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
public async Task<ISessionController> LaunchNew(
IDmbProvider dmbProvider,
IByondExecutableLock currentByondLock,
DreamDaemonLaunchParameters launchParameters,
bool apiValidate,
CancellationToken cancellationToken)
{
logger.LogTrace("Begin session launch...");
if (!launchParameters.Port.HasValue)
throw new InvalidOperationException("Given port is null!");
switch (dmbProvider.CompileJob.MinimumSecurityLevel)
{
case DreamDaemonSecurity.Ultrasafe:
break;
case DreamDaemonSecurity.Safe:
if (launchParameters.SecurityLevel == DreamDaemonSecurity.Ultrasafe)
launchParameters.SecurityLevel = DreamDaemonSecurity.Safe;
break;
case DreamDaemonSecurity.Trusted:
launchParameters.SecurityLevel = DreamDaemonSecurity.Trusted;
break;
default:
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid DreamDaemonSecurity value: {0}", dmbProvider.CompileJob.MinimumSecurityLevel));
}
var chatTrackingContext = chat.CreateTrackingContext();
try
{
// get the byond lock
var byondLock = currentByondLock ?? await byond.UseExecutables(Version.Parse(dmbProvider.CompileJob.ByondVersion), cancellationToken).ConfigureAwait(false);
try
{
logger.LogDebug(
"Launching session with CompileJob {0}...",
byondLock.Version.Semver(),
dmbProvider.CompileJob.Id);
if (launchParameters.SecurityLevel == DreamDaemonSecurity.Trusted)
await byondLock.TrustDmbPath(ioManager.ConcatPath(dmbProvider.Directory, dmbProvider.DmbName), cancellationToken).ConfigureAwait(false);
PortBindTest(launchParameters.Port.Value);
await CheckPagerIsNotRunning(cancellationToken).ConfigureAwait(false);
var accessIdentifier = cryptographySuite.GetSecureString();
var byondTopicSender = topicClientFactory.CreateTopicClient(
TimeSpan.FromMilliseconds(
launchParameters.TopicRequestTimeout.Value));
// set command line options
// more sanitization here cause it uses the same scheme
var parameters = $"{DMApiConstants.ParamApiVersion}={byondTopicSender.SanitizeString(DMApiConstants.Version.Semver().ToString())}&{byondTopicSender.SanitizeString(DMApiConstants.ParamServerPort)}={serverPortProvider.HttpApiPort}&{byondTopicSender.SanitizeString(DMApiConstants.ParamAccessIdentifier)}={byondTopicSender.SanitizeString(accessIdentifier)}";
var visibility = apiValidate ? "invisible" : "public";
// important to run on all ports to allow port changing
Guid? logFileGuid = null;
var arguments = String.Format(
CultureInfo.InvariantCulture,
"{0} -port {1} -ports 1-65535 {2}-close -{3} -{4}{5} -public -params \"{6}\"",
dmbProvider.DmbName,
launchParameters.Port.Value,
launchParameters.AllowWebClient.Value ? "-webclient " : String.Empty,
SecurityWord(launchParameters.SecurityLevel.Value),
visibility,
platformIdentifier.IsWindows
? $" -log {logFileGuid = Guid.NewGuid()}"
: String.Empty, // Just use stdout on linux
parameters);
// See https://github.com/tgstation/tgstation-server/issues/719
var noShellExecute = !platformIdentifier.IsWindows;
if (!apiValidate && dmbProvider.CompileJob.DMApiVersion == null)
logger.LogDebug("Session will have no DMAPI support!");
// launch dd
var process = processExecutor.LaunchProcess(
byondLock.DreamDaemonPath,
dmbProvider.Directory,
arguments,
noShellExecute,
noShellExecute,
noShellExecute: noShellExecute);
async Task<string> GetDDOutput()
{
if (!platformIdentifier.IsWindows)
return process.GetCombinedOutput();
var logFilePath = ioManager.ConcatPath(dmbProvider.Directory, logFileGuid.ToString());
try
{
var dreamDaemonLogBytes = await ioManager.ReadAllBytes(
logFilePath,
default)
.ConfigureAwait(false);
return Encoding.UTF8.GetString(dreamDaemonLogBytes);
}
finally
{
try
{
// DCT: No token available
await ioManager.DeleteFile(logFilePath, default).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogWarning("Failed to delete DreamDaemon log file {0}: {1}", logFilePath, ex);
}
}
}
// Log DD output
async Task PostLifetime()
{
try
{
var ddOutput = await GetDDOutput().ConfigureAwait(false);
logger.LogTrace(
"DreamDaemon Output:{0}{1}",
Environment.NewLine, ddOutput);
}
catch (Exception ex)
{
logger.LogWarning("Error reading DreamDaemon output: {0}", ex);
}
}
try
{
networkPromptReaper.RegisterProcess(process);
var runtimeInformation = CreateRuntimeInformation(
dmbProvider,
chatTrackingContext,
launchParameters.SecurityLevel.Value,
apiValidate);
var reattachInformation = new ReattachInformation(
dmbProvider,
process,
runtimeInformation,
accessIdentifier,
launchParameters.Port.Value);
var sessionController = new SessionController(
reattachInformation,
instance,
process,
byondLock,
byondTopicSender,
chatTrackingContext,
bridgeRegistrar,
chat,
assemblyInformationProvider,
loggerFactory.CreateLogger<SessionController>(),
PostLifetime,
launchParameters.StartupTimeout,
false,
apiValidate);
return sessionController;
}
catch
{
process.Terminate();
process.Dispose();
throw;
}
}
catch
{
if (currentByondLock == null)
byondLock.Dispose();
throw;
}
}
catch
{
chatTrackingContext.Dispose();
throw;
}
}
#pragma warning restore CA1506
/// <inheritdoc />
public async Task<ISessionController> Reattach(
ReattachInformation reattachInformation,
CancellationToken cancellationToken)
{
if (reattachInformation == null)
throw new ArgumentNullException(nameof(reattachInformation));
logger.LogTrace("Begin session reattach...");
var byondTopicSender = topicClientFactory.CreateTopicClient(reattachInformation.TopicRequestTimeout);
var chatTrackingContext = chat.CreateTrackingContext();
try
{
var byondLock = await byond.UseExecutables(Version.Parse(reattachInformation.Dmb.CompileJob.ByondVersion), cancellationToken).ConfigureAwait(false);
try
{
logger.LogDebug(
"Attaching to session PID: {0}, CompileJob: {1}...",
reattachInformation.ProcessId,
reattachInformation.Dmb.CompileJob.Id);
var process = processExecutor.GetProcess(reattachInformation.ProcessId);
if (process == null)
return null;
try
{
networkPromptReaper.RegisterProcess(process);
var runtimeInformation = CreateRuntimeInformation(
reattachInformation.Dmb,
chatTrackingContext,
null,
false);
reattachInformation.SetRuntimeInformation(runtimeInformation);
var controller = new SessionController(
reattachInformation,
instance,
process,
byondLock,
byondTopicSender,
chatTrackingContext,
bridgeRegistrar,
chat,
assemblyInformationProvider,
loggerFactory.CreateLogger<SessionController>(),
() => Task.CompletedTask,
null,
true,
false);
process = null;
byondLock = null;
chatTrackingContext = null;
return controller;
}
finally
{
process?.Dispose();
}
}
finally
{
byondLock?.Dispose();
}
}
finally
{
chatTrackingContext?.Dispose();
}
}
/// <summary>
/// Create <see cref="RuntimeInformation"/>.
/// </summary>
/// <param name="dmbProvider">The <see cref="IDmbProvider"/>.</param>
/// <param name="chatTrackingContext">The <see cref="IChatTrackingContext"/>.</param>
/// <param name="securityLevel">The <see cref="DreamDaemonSecurity"/> level if any.</param>
/// <param name="apiValidateOnly">The value of <see cref="RuntimeInformation.ApiValidateOnly"/>.</param>
/// <returns>A new <see cref="RuntimeInformation"/> <see langword="class"/>.</returns>
RuntimeInformation CreateRuntimeInformation(
IDmbProvider dmbProvider,
IChatTrackingContext chatTrackingContext,
DreamDaemonSecurity? securityLevel,
bool apiValidateOnly)
{
var revisionInfo = new Api.Models.Internal.RevisionInformation
{
CommitSha = dmbProvider.CompileJob.RevisionInformation.CommitSha,
OriginCommitSha = dmbProvider.CompileJob.RevisionInformation.OriginCommitSha
};
var testMerges = dmbProvider
.CompileJob
.RevisionInformation
.ActiveTestMerges?
.Select(x => x.TestMerge)
.Select(x => new TestMergeInformation(x, revisionInfo))
?? Enumerable.Empty<TestMergeInformation>();
return new RuntimeInformation(
assemblyInformationProvider,
serverPortProvider,
testMerges,
chatTrackingContext.Channels,
instance,
revisionInfo,
securityLevel,
apiValidateOnly);
}
/// <summary>
/// Make sure the BYOND pager is not running.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task CheckPagerIsNotRunning(CancellationToken cancellationToken)
{
if (!platformIdentifier.IsWindows)
return;
using var otherProcess = processExecutor.GetProcessByName("byond");
if (otherProcess == null)
return;
var otherUsernameTask = otherProcess.GetExecutingUsername(cancellationToken);
using var ourProcess = processExecutor.GetCurrentProcess();
var ourUserName = await ourProcess.GetExecutingUsername(cancellationToken).ConfigureAwait(false);
var otherUserName = await otherUsernameTask.ConfigureAwait(false);
if(otherUserName.Equals(ourUserName, StringComparison.Ordinal))
throw new JobException(ErrorCode.DeploymentPagerRunning);
}
}
}