tgstation-server
The /tg/station 13 server suite
SessionController.cs
Go to the documentation of this file.
1 using Byond.TopicSender;
2 using Microsoft.Extensions.Logging;
3 using Newtonsoft.Json;
4 using Newtonsoft.Json.Serialization;
5 using System;
6 using System.Collections.Generic;
7 using System.Globalization;
8 using System.Net;
9 using System.Threading;
10 using System.Threading.Tasks;
16 
17 namespace Tgstation.Server.Host.Components.Watchdog
18 {
21  {
23  public bool IsPrimary
24  {
25  get
26  {
27  CheckDisposed();
28  return reattachInformation.IsPrimary;
29  }
30  }
31 
34  {
35  get
36  {
37  if (!Lifetime.IsCompleted)
38  throw new InvalidOperationException("ApiValidated cannot be checked while Lifetime is incomplete!");
39  return apiValidationStatus;
40  }
41  }
42 
44  public IDmbProvider Dmb
45  {
46  get
47  {
48  CheckDisposed();
49  return reattachInformation.Dmb;
50  }
51  }
52 
54  public ushort? Port
55  {
56  get
57  {
58  CheckDisposed();
59  if (portClosedForReboot)
60  return null;
61  return reattachInformation.Port;
62  }
63  }
64 
67  {
68  get
69  {
70  CheckDisposed();
71  return reattachInformation.RebootState;
72  }
73  }
74 
76  public bool ClosePortOnReboot { get; set; }
77 
79  public bool TerminationWasRequested { get; private set; }
80 
82  public Task<LaunchResult> LaunchResult { get; }
83 
85  public Task<int> Lifetime => process.Lifetime;
86 
88  public Task OnReboot => rebootTcs.Task;
89 
94 
98  readonly IByondTopicSender byondTopicSender;
99 
104 
108  readonly IProcess process;
109 
114 
119 
123  readonly IChat chat;
124 
128  readonly ILogger<SessionController> logger;
129 
134 
138  TaskCompletionSource<bool> portAssignmentTcs;
142  ushort? nextPort;
143 
147  TaskCompletionSource<object> rebootTcs;
148 
153 
157  bool disposed;
158 
163 
167  bool released;
168 
182  public SessionController(ReattachInformation reattachInformation, IProcess process, IByondExecutableLock byondLock, IByondTopicSender byondTopicSender, IJsonTrackingContext chatJsonTrackingContext, ICommContext interopContext, IChat chat, ILogger<SessionController> logger, DreamDaemonSecurity? launchSecurityLevel, uint? startupTimeout)
183  {
184  this.chatJsonTrackingContext = chatJsonTrackingContext; //null valid
185  this.reattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
186  this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender));
187  this.process = process ?? throw new ArgumentNullException(nameof(process));
188  this.byondLock = byondLock ?? throw new ArgumentNullException(nameof(byondLock));
189  this.interopContext = interopContext ?? throw new ArgumentNullException(nameof(interopContext));
190  this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
191  this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
192 
193  this.launchSecurityLevel = launchSecurityLevel;
194 
195  interopContext.RegisterHandler(this);
196 
197  portClosedForReboot = false;
198  disposed = false;
199  apiValidationStatus = ApiValidationStatus.NeverValidated;
200  released = false;
201 
202  rebootTcs = new TaskCompletionSource<object>();
203 
204  process.Lifetime.ContinueWith(x => chatJsonTrackingContext.Active = false, TaskScheduler.Current);
205 
206  async Task<LaunchResult> GetLaunchResult()
207  {
208  var startTime = DateTimeOffset.Now;
209  Task toAwait = process.Startup;
210 
211  if (startupTimeout.HasValue)
212  toAwait = Task.WhenAny(process.Startup, Task.Delay(startTime.AddSeconds(startupTimeout.Value) - startTime));
213 
214  await toAwait.ConfigureAwait(false);
215 
216  var result = new LaunchResult
217  {
218  ExitCode = process.Lifetime.IsCompleted ? (int?)await process.Lifetime.ConfigureAwait(false) : null,
219  StartupTime = process.Startup.IsCompleted ? (TimeSpan?)(DateTimeOffset.Now - startTime) : null
220  };
221  return result;
222  };
223  LaunchResult = GetLaunchResult();
224 
225  logger.LogDebug("Created session controller. Primary: {0}, CommsKey: {1}, Port: {2}", IsPrimary, reattachInformation.AccessIdentifier, Port);
226  }
227 
232 #pragma warning disable CA1821 // Remove empty Finalizers //TODO remove this when https://github.com/dotnet/roslyn-analyzers/issues/1241 is fixed
233  ~SessionController() => Dispose(false);
234 #pragma warning restore CA1821 // Remove empty Finalizers
235 
237  public void Dispose()
238  {
239  Dispose(true);
240  GC.SuppressFinalize(this);
241  }
242 
244  void Dispose(bool disposing)
245  {
246  lock (this)
247  {
248  if (disposed)
249  return;
250  if (disposing)
251  {
252  if (!released)
253  {
254  process.Terminate();
255  byondLock.Dispose();
256  }
257  process.Dispose();
258  interopContext.Dispose();
259  Dmb?.Dispose(); //will be null when released
260  chatJsonTrackingContext.Dispose();
261  disposed = true;
262  }
263  else
264  {
265  if (logger != null)
266  logger.LogError("Being disposed via finalizer!");
267  if (!released)
268  if (process != null)
269  process.Terminate();
270  else if (logger != null)
271  logger.LogCritical("Unable to terminate active DreamDaemon session due to finalizer ordering!");
272  }
273  }
274  }
275 
277  public async Task HandleInterop(CommCommand command, CancellationToken cancellationToken)
278  {
279  if (command == null)
280  throw new ArgumentNullException(nameof(command));
281 
282  var query = command.Parameters;
283 
284  object content;
285  Action postRespond = null;
286  ushort? overrideResponsePort = null;
287  if (query.TryGetValue(Constants.DMParameterCommand, out var method))
288  {
289  content = new object();
290  switch (method)
291  {
293  try
294  {
295  var message = JsonConvert.DeserializeObject<Response>(command.RawJson, new JsonSerializerSettings
296  {
297  ContractResolver = new CamelCasePropertyNamesContractResolver()
298  });
299  if (message.ChannelIds == null)
300  throw new InvalidOperationException("Missing ChannelIds field!");
301  if (message.Message == null)
302  throw new InvalidOperationException("Missing Message field!");
303  await chat.SendMessage(message.Message, message.ChannelIds, cancellationToken).ConfigureAwait(false);
304  }
305  catch (Exception e)
306  {
307  logger.LogDebug("Exception while decoding chat message! Exception: {0}", e);
308  goto default;
309  }
310  break;
312  //currently unused, maybe in the future
313  break;
315  TerminationWasRequested = true;
316  process.Terminate();
317  return;
319  lock (this)
320  {
321  if (!query.TryGetValue(Constants.DMParameterData, out var stringPortObject) || !UInt16.TryParse(stringPortObject as string, out var currentPort))
322  {
324  logger.LogWarning("DreamDaemon sent new port command without providing it's own!");
325  content = new ErrorMessage { Message = "Missing stringified port as data parameter!" };
326  break;
327  }
328 
329  if (!nextPort.HasValue)
330  //not ready yet, so what we'll do is accept the random port DD opened on for now and change it later when we decide to
331  reattachInformation.Port = currentPort;
332  else
333  {
334  //nextPort is ready, tell DD to switch to that
335  //if it fails it'll kill itself
336  content = new Dictionary<string, ushort> { { Constants.DMParameterData, nextPort.Value } };
337  reattachInformation.Port = nextPort.Value;
338  overrideResponsePort = currentPort;
339  nextPort = null;
340 
341  //we'll also get here from SetPort so complete that task
342  var tmpTcs = portAssignmentTcs;
343  portAssignmentTcs = null;
344  if (tmpTcs != null)
345  postRespond = () => tmpTcs.SetResult(true);
346  }
347 
348  portClosedForReboot = false;
349  }
350  break;
352  if (!launchSecurityLevel.HasValue)
353  {
354  logger.LogWarning("DreamDaemon requested API validation but no intial security level was passed to the session controller!");
355  apiValidationStatus = ApiValidationStatus.UnaskedValidationRequest;
356  content = new ErrorMessage { Message = "Invalid API validation request!" };
357  break;
358  }
359  if (!query.TryGetValue(Constants.DMParameterData, out var stringMinimumSecurityLevelObject) || !Enum.TryParse<DreamDaemonSecurity>(stringMinimumSecurityLevelObject as string, out var minimumSecurityLevel))
360  apiValidationStatus = ApiValidationStatus.BadValidationRequest;
361  else
362  switch (minimumSecurityLevel)
363  {
364  case DreamDaemonSecurity.Safe:
365  apiValidationStatus = ApiValidationStatus.RequiresSafe;
366  break;
367  case DreamDaemonSecurity.Ultrasafe:
368  apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
369  break;
370  case DreamDaemonSecurity.Trusted:
371  apiValidationStatus = ApiValidationStatus.RequiresTrusted;
372  break;
373  default:
374  throw new InvalidOperationException("Enum.TryParse failed to validate the DreamDaemonSecurity range!");
375  }
376  break;
378  if (ClosePortOnReboot)
379  {
380  chatJsonTrackingContext.Active = false;
381  content = new Dictionary<string, int> { { Constants.DMParameterData, 0 } };
382  portClosedForReboot = true;
383  }
384  var oldTcs = rebootTcs;
385  rebootTcs = new TaskCompletionSource<object>();
386  postRespond = () => oldTcs.SetResult(null);
387  break;
388  default:
389  content = new ErrorMessage { Message = "Requested command not supported!" };
390  break;
391  }
392  }
393  else
394  content = new ErrorMessage { Message = "Missing command parameter!" };
395 
396  var json = JsonConvert.SerializeObject(content);
397  var response = await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(Constants.DMTopicInteropResponse), byondTopicSender.SanitizeString(Constants.DMParameterData), byondTopicSender.SanitizeString(json)), overrideResponsePort, cancellationToken).ConfigureAwait(false);
398 
399  if (response != Constants.DMResponseSuccess)
400  logger.LogWarning("Received error response while responding to interop: {0}", response);
401 
402  postRespond?.Invoke();
403  }
404 
409  {
410  if (disposed)
411  throw new ObjectDisposedException(nameof(SessionController));
412  }
413 
415  public void EnableCustomChatCommands() => chatJsonTrackingContext.Active = true;
416 
419  {
420  CheckDisposed();
421  //we still don't want to dispose the dmb yet, even though we're keeping it alive
422  var tmpProvider = reattachInformation.Dmb;
423  reattachInformation.Dmb = null;
424  released = true;
425  Dispose();
426  byondLock.DoNotDeleteThisSession();
427  tmpProvider.KeepAlive();
428  reattachInformation.Dmb = tmpProvider;
429  return reattachInformation;
430  }
431 
433  public Task<string> SendCommand(string command, CancellationToken cancellationToken) => SendCommand(command, null, cancellationToken);
434 
435  async Task<string> SendCommand(string command, ushort? overridePort, CancellationToken cancellationToken)
436  {
437  try
438  {
439  var commandString = String.Format(CultureInfo.InvariantCulture,
440  "?{0}={1}&{2}={3}",
441  byondTopicSender.SanitizeString(Constants.DMInteropAccessIdentifier),
442  byondTopicSender.SanitizeString(reattachInformation.AccessIdentifier),
443  byondTopicSender.SanitizeString(Constants.DMParameterCommand),
444  //intentionally don't sanitize command, that's up to the caller
445  command);
446 
447  var targetPort = overridePort ?? reattachInformation.Port;
448  logger.LogTrace("Export to :{0}. Query: {1}", targetPort, commandString);
449 
450  return await byondTopicSender.SendTopic(
451  new IPEndPoint(IPAddress.Loopback, targetPort),
452  commandString,
453  cancellationToken).ConfigureAwait(false);
454  }
455  catch (OperationCanceledException)
456  {
457  throw;
458  }
459  catch (Exception e)
460  {
461  logger.LogInformation("Send command exception:{0}{1}", Environment.NewLine, e.Message);
462  return null;
463  }
464  }
465 
467  public Task<bool> SetPort(ushort port, CancellationToken cancellationToken)
468  {
469  CheckDisposed();
470 
471  if (port == 0)
472  throw new ArgumentOutOfRangeException(nameof(port), port, "port must not be zero!");
473 
474  async Task<bool> ImmediateTopicPortChange()
475  {
476  var commandResult = await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(Constants.DMTopicChangePort), byondTopicSender.SanitizeString(Constants.DMParameterData), byondTopicSender.SanitizeString(port.ToString(CultureInfo.InvariantCulture))), cancellationToken).ConfigureAwait(false);
477 
478  if (commandResult != Constants.DMResponseSuccess)
479  {
480  logger.LogWarning("Failed port change! DD says: {0}", commandResult);
481  return false;
482  }
483 
484  reattachInformation.Port = port;
485  return true;
486  }
487 
488  lock (this)
489  if (portClosedForReboot)
490  {
491  if (portAssignmentTcs != null)
492  throw new InvalidOperationException("A port change operation is already in progress!");
493  nextPort = port;
494  portAssignmentTcs = new TaskCompletionSource<bool>();
495  return portAssignmentTcs.Task;
496  }
497  else
498  return ImmediateTopicPortChange();
499  }
500 
502  public async Task<bool> SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
503  {
504  if (RebootState == newRebootState)
505  return true;
506  reattachInformation.RebootState = newRebootState;
507  return await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(Constants.DMTopicChangeReboot), byondTopicSender.SanitizeString(Constants.DMParameterData), (int)newRebootState), cancellationToken).ConfigureAwait(false) == Constants.DMResponseSuccess;
508  }
509 
511  public void ResetRebootState()
512  {
513  CheckDisposed();
514  reattachInformation.RebootState = RebootState.Normal;
515  }
516 
518  public void SetHighPriority() => process.SetHighPriority();
519  }
520 }
bool disposed
If the SessionController has been disposed
readonly ICommContext interopContext
The ICommContext for the SessionController
Task< int > Lifetime
The Task<TResult> resulting in the exit code of the process
Definition: IProcessBase.cs:14
IReadOnlyDictionary< string, object > Parameters
The dictionary of the CommCommand
Definition: CommCommand.cs:13
Represents the result of trying to start a DD process
Definition: LaunchResult.cs:9
readonly ReattachInformation reattachInformation
The up to date ReattachInformation
Task< bool > SetPort(ushort port, CancellationToken cancellationToken)
Causes the world to start listening on a newPort
readonly IProcess process
The IProcess for the SessionController
ReattachInformation Release()
Releases the IProcess without terminating it. Also calls System.IDisposable.Dispose ...
Represents a registration of an interop session
Definition: ICommContext.cs:8
readonly IJsonTrackingContext chatJsonTrackingContext
The IJsonTrackingContext for the SessionController
async Task< bool > SetRebootState(RebootState newRebootState, CancellationToken cancellationToken)
Attempts to change the current RebootState to newRebootState
bool Active
If the IJsonTrackingContext should be used for GetCustomCommands(CancellationToken) ...
readonly IByondExecutableLock byondLock
The IByondExecutableLock for the SessionController
readonly IChat chat
The IChat for the SessionController
Task Startup
The Task representing the time until the IProcess becomes "idle"
Definition: IProcess.cs:19
For managing connected chat services
Definition: IChat.cs:13
void RegisterHandler(ICommHandler handler)
Register a handler with the ICommContext
SessionController(ReattachInformation reattachInformation, IProcess process, IByondExecutableLock byondLock, IByondTopicSender byondTopicSender, IJsonTrackingContext chatJsonTrackingContext, ICommContext interopContext, IChat chat, ILogger< SessionController > logger, DreamDaemonSecurity?launchSecurityLevel, uint?startupTimeout)
Construct a SessionController
Handles communication with a DreamDaemon IProcess
string RawJson
The raw JSON of the CommCommand
Definition: CommCommand.cs:18
readonly DreamDaemonSecurity launchSecurityLevel
The DreamDaemonSecurity level the process was launched with
TaskCompletionSource< bool > portAssignmentTcs
The TaskCompletionSource<TResult> SetPort(ushort, CancellationToken) waits on when DreamDaemon curren...
void CheckDisposed()
Throws an ObjectDisposedException if Dispose(bool) has been called
async Task< string > SendCommand(string command, ushort?overridePort, CancellationToken cancellationToken)
ushort nextPort
The port to assign DreamDaemon when it queries for it
readonly ILogger< SessionController > logger
The ILogger for the SessionController
TaskCompletionSource< object > rebootTcs
The TaskCompletionSource<TResult> that completes when DD tells us about a reboot
Abstraction over a System.Diagnostics.Process
Definition: IProcess.cs:9
Represents a tracking of dynamic chat json files
Represents usage of the two primary BYOND server executables
ApiValidationStatus apiValidationStatus
The ApiValidationStatus for the SessionController
void ResetRebootState()
Changes RebootState to Components.Watchdog.RebootState.Normal without telling the DMAPI ...
Represents a BYOND installation
Definition: Byond.cs:8
DreamDaemonSecurity
DreamDaemon&#39;s security level
Provides absolute paths to the latest compiled .dmbs
Definition: IDmbProvider.cs:9
bool portClosedForReboot
If we know DreamDaemon currently has it&#39;s port closed
readonly IByondTopicSender byondTopicSender
The IByondTopicSender for the SessionController
async Task HandleInterop(CommCommand command, CancellationToken cancellationToken)
Handle a command
Represents an error message returned by the server
Definition: ErrorMessage.cs:8
RebootState
Represents the action to take when /world/Reboot() is called
Definition: RebootState.cs:6
Parameters necessary for duplicating a ISessionController session
ApiValidationStatus
Status of DMAPI validation
string AccessIdentifier
Used to identify and authenticate the DreamDaemon instance
bool released
If process should be kept alive instead
Represents a chat message requested by DD
Definition: Response.cs:8