tgstation-server  4.4.0
The /tg/station 13 server suite
SetupWizard.cs
Go to the documentation of this file.
1 using Microsoft.Data.Sqlite;
2 using Microsoft.Extensions.Hosting;
3 using Microsoft.Extensions.Logging;
4 using Microsoft.Extensions.Options;
5 using MySql.Data.MySqlClient;
6 using Newtonsoft.Json;
7 using Npgsql;
8 using System;
9 using System.Collections.Generic;
10 using System.Data.Common;
11 using System.Data.SqlClient;
12 using System.Globalization;
13 using System.IO;
14 using System.Linq;
15 using System.Text;
16 using System.Text.RegularExpressions;
17 using System.Threading;
18 using System.Threading.Tasks;
22 using Tgstation.Server.Host.IO;
24 
25 namespace Tgstation.Server.Host.Setup
26 {
28  sealed class SetupWizard : IHostedService
29  {
34 
38  readonly IConsole console;
39 
43  readonly IHostEnvironment hostingEnvironment;
44 
49 
54 
59 
64 
68  readonly IHostApplicationLifetime applicationLifetime;
69 
74 
87  public SetupWizard(
88  IIOManager ioManager,
89  IConsole console,
90  IHostEnvironment hostingEnvironment,
91  IAssemblyInformationProvider assemblyInformationProvider,
92  IDatabaseConnectionFactory dbConnectionFactory,
93  IPlatformIdentifier platformIdentifier,
94  IAsyncDelayer asyncDelayer,
95  IHostApplicationLifetime applicationLifetime,
96  IOptions<GeneralConfiguration> generalConfigurationOptions)
97  {
98  this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
99  this.console = console ?? throw new ArgumentNullException(nameof(console));
100  this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment));
101  this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
102  this.dbConnectionFactory = dbConnectionFactory ?? throw new ArgumentNullException(nameof(dbConnectionFactory));
103  this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
104  this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
105  this.applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime));
106  generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
107  }
108 
115  async Task<bool> PromptYesNo(string question, CancellationToken cancellationToken)
116  {
117  do
118  {
119  await console.WriteAsync(question, false, cancellationToken).ConfigureAwait(false);
120  var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
121  var upperResponse = responseString.ToUpperInvariant();
122  if (upperResponse == "Y" || upperResponse == "YES")
123  return true;
124  else if (upperResponse == "N" || upperResponse == "NO")
125  return false;
126  await console.WriteAsync("Invalid response!", true, cancellationToken).ConfigureAwait(false);
127  }
128  while (true);
129  }
130 
136  async Task<ushort?> PromptForHostingPort(CancellationToken cancellationToken)
137  {
138  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
139  await console.WriteAsync("What port would you like to connect to TGS on?", true, cancellationToken).ConfigureAwait(false);
140  await console.WriteAsync("Note: If this is a docker container with the default port already mapped, use the default.", true, cancellationToken).ConfigureAwait(false);
141 
142  do
143  {
144  await console.WriteAsync(
145  $"API Port (leave blank for default of {GeneralConfiguration.DefaultApiPort}): ",
146  false,
147  cancellationToken)
148  .ConfigureAwait(false);
149  var portString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
150  if (String.IsNullOrWhiteSpace(portString))
151  return null;
152  if (UInt16.TryParse(portString, out var port) && port != 0)
153  return port;
154  await console.WriteAsync("Invalid port! Please enter a value between 1 and 65535", true, cancellationToken).ConfigureAwait(false);
155  }
156  while (true);
157  }
158 
169  DbConnection testConnection,
170  DatabaseConfiguration databaseConfiguration,
171  string databaseName,
172  bool dbExists,
173  CancellationToken cancellationToken)
174  {
175  bool isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
176  using (testConnection)
177  {
178  await console.WriteAsync("Testing connection...", true, cancellationToken).ConfigureAwait(false);
179  await testConnection.OpenAsync(cancellationToken).ConfigureAwait(false);
180  await console.WriteAsync("Connection successful!", true, cancellationToken).ConfigureAwait(false);
181 
182  if (databaseConfiguration.DatabaseType == DatabaseType.MariaDB
183  || databaseConfiguration.DatabaseType == DatabaseType.MySql
184  || databaseConfiguration.DatabaseType == DatabaseType.PostgresSql)
185  {
186  await console.WriteAsync($"Checking {databaseConfiguration.DatabaseType} version...", true, cancellationToken).ConfigureAwait(false);
187  using var command = testConnection.CreateCommand();
188  command.CommandText = "SELECT VERSION()";
189  var fullVersion = (string)await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
190  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion), true, cancellationToken).ConfigureAwait(false);
191 
192  if (databaseConfiguration.DatabaseType == DatabaseType.PostgresSql)
193  {
194  var splits = fullVersion.Split(' ');
195  databaseConfiguration.ServerVersion = splits[1].TrimEnd(',');
196  }
197  else
198  {
199  var splits = fullVersion.Split('-');
200  databaseConfiguration.ServerVersion = splits.First();
201  }
202  }
203 
204  if (!isSqliteDB && !dbExists)
205  {
206  await console.WriteAsync("Testing create DB permission...", true, cancellationToken).ConfigureAwait(false);
207  using (var command = testConnection.CreateCommand())
208  {
209  // I really don't care about user sanitization here, they want to fuck their own DB? so be it
210 #pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
211  command.CommandText = $"CREATE DATABASE {databaseName}";
212 #pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
213  await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
214  }
215 
216  await console.WriteAsync("Success!", true, cancellationToken).ConfigureAwait(false);
217  await console.WriteAsync("Dropping test database...", true, cancellationToken).ConfigureAwait(false);
218  using (var command = testConnection.CreateCommand())
219  {
220 #pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
221  command.CommandText = $"DROP DATABASE {databaseName}";
222 #pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
223  try
224  {
225  await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
226  }
227  catch (OperationCanceledException)
228  {
229  throw;
230  }
231  catch (Exception e)
232  {
233  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
234  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
235  await console.WriteAsync("This should be okay, but you may want to manually drop the database before continuing!", true, cancellationToken).ConfigureAwait(false);
236  await console.WriteAsync("Press any key to continue...", true, cancellationToken).ConfigureAwait(false);
237  await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false);
238  }
239  }
240  }
241  }
242 
243  if (isSqliteDB && !dbExists)
244  await Task.WhenAll(
245  console.WriteAsync("Deleting test database file...", true, cancellationToken),
246  ioManager.DeleteFile(databaseName, cancellationToken)).ConfigureAwait(false);
247  }
248 
249  async Task<string> ValidateNonExistantSqliteDBName(string databaseName, CancellationToken cancellationToken)
250  {
251  var resolvedPath = ioManager.ResolvePath(databaseName);
252  try
253  {
254  var directoryName = ioManager.GetDirectoryName(resolvedPath);
255  bool directoryExisted = await ioManager.DirectoryExists(directoryName, cancellationToken).ConfigureAwait(false);
256  await ioManager.CreateDirectory(directoryName, cancellationToken).ConfigureAwait(false);
257  try
258  {
259  await ioManager.WriteAllBytes(resolvedPath, Array.Empty<byte>(), cancellationToken).ConfigureAwait(false);
260  }
261  catch
262  {
263  if (!directoryExisted)
264  await ioManager.DeleteDirectory(directoryName, cancellationToken).ConfigureAwait(false);
265  throw;
266  }
267  }
268  catch (IOException)
269  {
270  return null;
271  }
272 
273  if (!Path.IsPathRooted(databaseName))
274  {
275  await console.WriteAsync("Note, this relative path (currently) resolves to the following:", true, cancellationToken).ConfigureAwait(false);
276  await console.WriteAsync(resolvedPath, true, cancellationToken).ConfigureAwait(false);
277  bool writeResolved = await PromptYesNo(
278  "Would you like to save the relative path in the configuration? If not, the full path will be saved. (y/n): ",
279  cancellationToken)
280  .ConfigureAwait(false);
281 
282  if (writeResolved)
283  databaseName = resolvedPath;
284  }
285 
286  await ioManager.DeleteFile(databaseName, cancellationToken).ConfigureAwait(false);
287  return databaseName;
288  }
289 
296  async Task<DatabaseType> PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
297  {
298  if (firstTime)
299  {
300  await console.WriteAsync(String.Empty, true, cancellationToken).ConfigureAwait(false);
301  await console.WriteAsync(
302  "NOTE: It is HIGHLY reccommended that TGS runs on a complete relational database, specfically *NOT* Sqlite.",
303  true,
304  cancellationToken)
305  .ConfigureAwait(false);
306  await console.WriteAsync(
307  "Sqlite, by nature cannot perform several DDL operations. Because of this future compatiblility cannot be guaranteed.",
308  true,
309  cancellationToken)
310  .ConfigureAwait(false);
311  await console.WriteAsync(
312  "This means that you may not be able to update to the next minor version of TGS4 without a clean re-installation!",
313  true,
314  cancellationToken)
315  .ConfigureAwait(false);
316  await console.WriteAsync(
317  "Please consider taking the time to set up a relational database if this is meant to be a long-standing server.",
318  true,
319  cancellationToken)
320  .ConfigureAwait(false);
321  await console.WriteAsync(String.Empty, true, cancellationToken).ConfigureAwait(false);
322 
323  await asyncDelayer.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
324  }
325 
326  await console.WriteAsync("What SQL database type will you be using?", true, cancellationToken).ConfigureAwait(false);
327  do
328  {
329  await console.WriteAsync(
330  String.Format(
331  CultureInfo.InvariantCulture,
332  "Please enter one of {0}, {1}, {2}, {3} or {4}: ",
333  DatabaseType.MariaDB,
334  DatabaseType.MySql,
335  DatabaseType.PostgresSql,
336  DatabaseType.SqlServer,
337  DatabaseType.Sqlite),
338  false,
339  cancellationToken)
340  .ConfigureAwait(false);
341  var databaseTypeString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
342  if (Enum.TryParse<DatabaseType>(databaseTypeString, out var databaseType))
343  return databaseType;
344 
345  await console.WriteAsync("Invalid database type!", true, cancellationToken).ConfigureAwait(false);
346  }
347  while (true);
348  }
349 
355  #pragma warning disable CA1502 // TODO: Decomplexify
356  async Task<DatabaseConfiguration> ConfigureDatabase(CancellationToken cancellationToken)
357  {
358  bool firstTime = true;
359  do
360  {
361  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
362 
363  var databaseConfiguration = new DatabaseConfiguration
364  {
365  DatabaseType = await PromptDatabaseType(firstTime, cancellationToken).ConfigureAwait(false)
366  };
367  firstTime = false;
368 
369  string serverAddress = null;
370  ushort? serverPort = null;
371 
372  bool isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
373  if (!isSqliteDB)
374  do
375  {
376  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
377  await console.WriteAsync("Enter the server's address and port [<server>:<port> or <server>] (blank for local): ", false, cancellationToken).ConfigureAwait(false);
378  serverAddress = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
379  if (String.IsNullOrWhiteSpace(serverAddress))
380  serverAddress = null;
381  else if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer)
382  {
383  var match = Regex.Match(serverAddress, @"^(?<server>.+):(?<port>.+)$");
384  if (match.Success)
385  {
386  serverAddress = match.Groups["server"].Value;
387  var portString = match.Groups["port"].Value;
388  if (UInt16.TryParse(portString, out var port))
389  serverPort = port;
390  else
391  {
392  await console.WriteAsync($"Failed to parse port \"{portString}\", please try again.", true, cancellationToken).ConfigureAwait(false);
393  continue;
394  }
395  }
396  }
397 
398  break;
399  }
400  while (true);
401 
402  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
403  await console.WriteAsync($"Enter the database {(isSqliteDB ? "file path" : "name")} (Can be from previous installation. Otherwise, should not exist): ", false, cancellationToken).ConfigureAwait(false);
404 
405  string databaseName;
406  bool dbExists = false;
407  do
408  {
409  databaseName = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
410  if (!String.IsNullOrWhiteSpace(databaseName))
411  {
412  if (isSqliteDB)
413  {
414  dbExists = await ioManager.FileExists(databaseName, cancellationToken).ConfigureAwait(false);
415  if (!dbExists)
416  databaseName = await ValidateNonExistantSqliteDBName(databaseName, cancellationToken).ConfigureAwait(false);
417  }
418  else
419  dbExists = await PromptYesNo("Does this database already exist? If not, we will attempt to CREATE it. (y/n): ", cancellationToken).ConfigureAwait(false);
420  }
421 
422  if (String.IsNullOrWhiteSpace(databaseName))
423  await console.WriteAsync("Invalid database name!", true, cancellationToken).ConfigureAwait(false);
424  else
425  break;
426  }
427  while (true);
428 
429  bool useWinAuth;
430  if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && platformIdentifier.IsWindows)
431  useWinAuth = await PromptYesNo("Use Windows Authentication? (y/n): ", cancellationToken).ConfigureAwait(false);
432  else
433  useWinAuth = false;
434 
435  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
436 
437  string username = null;
438  string password = null;
439  if (!isSqliteDB)
440  if (!useWinAuth)
441  {
442  await console.WriteAsync("Enter username: ", false, cancellationToken).ConfigureAwait(false);
443  username = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
444  await console.WriteAsync("Enter password: ", false, cancellationToken).ConfigureAwait(false);
445  password = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false);
446  }
447  else
448  {
449  await console.WriteAsync("IMPORTANT: If using the service runner, ensure this computer's LocalSystem account has CREATE DATABASE permissions on the target server!", true, cancellationToken).ConfigureAwait(false);
450  await console.WriteAsync("The account it uses in MSSQL is usually \"NT AUTHORITY\\SYSTEM\" and the role it needs is usually \"dbcreator\".", true, cancellationToken).ConfigureAwait(false);
451  await console.WriteAsync("We'll run a sanity test here, but it won't be indicative of the service's permissions if that is the case", true, cancellationToken).ConfigureAwait(false);
452  }
453 
454  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
455 
456  DbConnection testConnection;
457  void CreateTestConnection(string connectionString) =>
458  testConnection = dbConnectionFactory.CreateConnection(
459  connectionString,
460  databaseConfiguration.DatabaseType);
461 
462  switch (databaseConfiguration.DatabaseType)
463  {
464  case DatabaseType.SqlServer:
465  {
466  var csb = new SqlConnectionStringBuilder
467  {
468  ApplicationName = assemblyInformationProvider.VersionPrefix,
469  DataSource = serverAddress ?? "(local)"
470  };
471 
472  if (useWinAuth)
473  csb.IntegratedSecurity = true;
474  else
475  {
476  csb.UserID = username;
477  csb.Password = password;
478  }
479 
480  CreateTestConnection(csb.ConnectionString);
481  csb.InitialCatalog = databaseName;
482  databaseConfiguration.ConnectionString = csb.ConnectionString;
483  }
484 
485  break;
486  case DatabaseType.MariaDB:
487  case DatabaseType.MySql:
488  {
489  // MySQL/MariaDB
490  var csb = new MySqlConnectionStringBuilder
491  {
492  Server = serverAddress ?? "127.0.0.1",
493  UserID = username,
494  Password = password
495  };
496 
497  if (serverPort.HasValue)
498  csb.Port = serverPort.Value;
499 
500  CreateTestConnection(csb.ConnectionString);
501  csb.Database = databaseName;
502  databaseConfiguration.ConnectionString = csb.ConnectionString;
503  }
504 
505  break;
506  case DatabaseType.Sqlite:
507  {
508  var csb = new SqliteConnectionStringBuilder
509  {
510  DataSource = databaseName,
511  Mode = dbExists ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate
512  };
513 
514  CreateTestConnection(csb.ConnectionString);
515  databaseConfiguration.ConnectionString = csb.ConnectionString;
516  }
517 
518  break;
519  case DatabaseType.PostgresSql:
520  {
521  var csb = new NpgsqlConnectionStringBuilder
522  {
523  ApplicationName = assemblyInformationProvider.VersionPrefix,
524  Host = serverAddress ?? "127.0.0.1",
525  Password = password,
526  Username = username
527  };
528 
529  if (serverPort.HasValue)
530  csb.Port = serverPort.Value;
531 
532  CreateTestConnection(csb.ConnectionString);
533  csb.Database = databaseName;
534  databaseConfiguration.ConnectionString = csb.ConnectionString;
535  }
536 
537  break;
538  default:
539  throw new InvalidOperationException("Invalid DatabaseType!");
540  }
541 
542  try
543  {
544  await TestDatabaseConnection(testConnection, databaseConfiguration, databaseName, dbExists, cancellationToken).ConfigureAwait(false);
545 
546  return databaseConfiguration;
547  }
548  catch (OperationCanceledException)
549  {
550  throw;
551  }
552  catch (Exception e)
553  {
554  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
555  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
556  await console.WriteAsync("Retrying database configuration...", true, cancellationToken).ConfigureAwait(false);
557  }
558  }
559  while (true);
560  }
561  #pragma warning restore CA1502
562 
568  async Task<GeneralConfiguration> ConfigureGeneral(CancellationToken cancellationToken)
569  {
570  var newGeneralConfiguration = new GeneralConfiguration
571  {
573  };
574 
575  do
576  {
577  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
578  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", newGeneralConfiguration.MinimumPasswordLength), false, cancellationToken).ConfigureAwait(false);
579  var passwordLengthString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
580  if (String.IsNullOrWhiteSpace(passwordLengthString))
581  break;
582  if (UInt32.TryParse(passwordLengthString, out var passwordLength) && passwordLength >= 0)
583  {
584  newGeneralConfiguration.MinimumPasswordLength = passwordLength;
585  break;
586  }
587 
588  await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false);
589  }
590  while (true);
591 
592  do
593  {
594  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
595  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Default timeout for sending and receiving BYOND topics (ms, 0 for infinite, leave blank for default of {0}): ", newGeneralConfiguration.ByondTopicTimeout), false, cancellationToken).ConfigureAwait(false);
596  var topicTimeoutString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
597  if (String.IsNullOrWhiteSpace(topicTimeoutString))
598  break;
599  if (UInt32.TryParse(topicTimeoutString, out var topicTimeout) && topicTimeout >= 0)
600  {
601  newGeneralConfiguration.ByondTopicTimeout = topicTimeout;
602  break;
603  }
604 
605  await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false);
606  }
607  while (true);
608 
609  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
610  await console.WriteAsync("Enter a GitHub personal access token to bypass some rate limits (this is optional and does not require any scopes)", true, cancellationToken).ConfigureAwait(false);
611  await console.WriteAsync("GitHub personal access token: ", false, cancellationToken).ConfigureAwait(false);
612  newGeneralConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false);
613  if (String.IsNullOrWhiteSpace(newGeneralConfiguration.GitHubAccessToken))
614  newGeneralConfiguration.GitHubAccessToken = null;
615 
616  return newGeneralConfiguration;
617  }
618 
624  async Task<FileLoggingConfiguration> ConfigureLogging(CancellationToken cancellationToken)
625  {
626  var fileLoggingConfiguration = new FileLoggingConfiguration();
627  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
628  fileLoggingConfiguration.Disable = !await PromptYesNo("Enable file logging? (y/n): ", cancellationToken).ConfigureAwait(false);
629 
630  if (!fileLoggingConfiguration.Disable)
631  {
632  do
633  {
634  await console.WriteAsync("Log file directory path (leave blank for default): ", false, cancellationToken).ConfigureAwait(false);
635  fileLoggingConfiguration.Directory = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
636  if (String.IsNullOrWhiteSpace(fileLoggingConfiguration.Directory))
637  {
638  fileLoggingConfiguration.Directory = null;
639  break;
640  }
641 
642  // test a write of it
643  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
644  await console.WriteAsync("Testing directory access...", true, cancellationToken).ConfigureAwait(false);
645  try
646  {
647  await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken).ConfigureAwait(false);
648  var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid()));
649  await ioManager.WriteAllBytes(testFile, Array.Empty<byte>(), cancellationToken).ConfigureAwait(false);
650  try
651  {
652  await ioManager.DeleteFile(testFile, cancellationToken).ConfigureAwait(false);
653  }
654  catch (OperationCanceledException)
655  {
656  throw;
657  }
658  catch (Exception e)
659  {
660  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken).ConfigureAwait(false);
661  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
662  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
663  }
664 
665  break;
666  }
667  catch (OperationCanceledException)
668  {
669  throw;
670  }
671  catch (Exception e)
672  {
673  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
674  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
675  await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken).ConfigureAwait(false);
676  }
677  }
678  while (true);
679 
680  async Task<LogLevel?> PromptLogLevel(string question)
681  {
682  do
683  {
684  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
685  await console.WriteAsync(question, true, cancellationToken).ConfigureAwait(false);
686  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Enter one of {0}/{1}/{2}/{3}/{4}/{5} (leave blank for default): ", nameof(LogLevel.Trace), nameof(LogLevel.Debug), nameof(LogLevel.Information), nameof(LogLevel.Warning), nameof(LogLevel.Error), nameof(LogLevel.Critical)), false, cancellationToken).ConfigureAwait(false);
687  var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
688  if (String.IsNullOrWhiteSpace(responseString))
689  return null;
690  if (Enum.TryParse<LogLevel>(responseString, out var logLevel) && logLevel != LogLevel.None)
691  return logLevel;
692  await console.WriteAsync("Invalid log level!", true, cancellationToken).ConfigureAwait(false);
693  }
694  while (true);
695  }
696 
697  fileLoggingConfiguration.LogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for normal logs (default {0}).", fileLoggingConfiguration.LogLevel)).ConfigureAwait(false) ?? fileLoggingConfiguration.LogLevel;
698  fileLoggingConfiguration.MicrosoftLogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for Microsoft logs (VERY verbose, default {0}).", fileLoggingConfiguration.MicrosoftLogLevel)).ConfigureAwait(false) ?? fileLoggingConfiguration.MicrosoftLogLevel;
699  }
700 
701  return fileLoggingConfiguration;
702  }
703 
709  async Task<ControlPanelConfiguration> ConfigureControlPanel(CancellationToken cancellationToken)
710  {
711  var config = new ControlPanelConfiguration
712  {
713  Enable = await PromptYesNo("Enable the web control panel (Incomplete)? (y/n): ", cancellationToken).ConfigureAwait(false),
714  AllowAnyOrigin = await PromptYesNo("Allow web control panels hosted elsewhere to access the server? (Access-Control-Allow-Origin: *) (y/n): ", cancellationToken).ConfigureAwait(false)
715  };
716 
717  if (!config.AllowAnyOrigin)
718  {
719  await console.WriteAsync("Enter a comma seperated list of CORS allowed origins (optional): ", false, cancellationToken).ConfigureAwait(false);
720  var commaSeperatedOrigins = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
721  if (!String.IsNullOrWhiteSpace(commaSeperatedOrigins))
722  {
723  var splits = commaSeperatedOrigins.Split(',');
724  config.AllowedOrigins = new List<string>(splits.Select(x => x.Trim()));
725  }
726  }
727 
728  return config;
729  }
730 
742  async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration newGeneralConfiguration, FileLoggingConfiguration fileLoggingConfiguration, ControlPanelConfiguration controlPanelConfiguration, CancellationToken cancellationToken)
743  {
744  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken).ConfigureAwait(false);
745 
746  newGeneralConfiguration.ApiPort = hostingPort ?? GeneralConfiguration.DefaultApiPort;
747  newGeneralConfiguration.ConfigVersion = GeneralConfiguration.CurrentConfigVersion;
748  var map = new Dictionary<string, object>()
749  {
750  { DatabaseConfiguration.Section, databaseConfiguration },
751  { GeneralConfiguration.Section, newGeneralConfiguration },
752  { FileLoggingConfiguration.Section, fileLoggingConfiguration },
753  { ControlPanelConfiguration.Section, controlPanelConfiguration }
754  };
755 
756  var json = JsonConvert.SerializeObject(map, Formatting.Indented);
757  var configBytes = Encoding.UTF8.GetBytes(json);
758 
759  try
760  {
761  await ioManager.WriteAllBytes(userConfigFileName, configBytes, cancellationToken).ConfigureAwait(false);
762  }
763  catch (OperationCanceledException)
764  {
765  throw;
766  }
767  catch (Exception e)
768  {
769  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
770  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
771  await console.WriteAsync("For your convienence, here's the json we tried to write out:", true, cancellationToken).ConfigureAwait(false);
772  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
773  await console.WriteAsync(json, true, cancellationToken).ConfigureAwait(false);
774  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
775  await console.WriteAsync("Press any key to exit...", true, cancellationToken).ConfigureAwait(false);
776  await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false);
777  throw new OperationCanceledException();
778  }
779  }
780 
787  async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
788  {
789  // welcome message
790  await console.WriteAsync("Welcome to tgstation-server 4!", true, cancellationToken).ConfigureAwait(false);
791  await console.WriteAsync("This wizard will help you configure your server.", true, cancellationToken).ConfigureAwait(false);
792 
793  var hostingPort = await PromptForHostingPort(cancellationToken).ConfigureAwait(false);
794 
795  var databaseConfiguration = await ConfigureDatabase(cancellationToken).ConfigureAwait(false);
796 
797  var newGeneralConfiguration = await ConfigureGeneral(cancellationToken).ConfigureAwait(false);
798 
799  var fileLoggingConfiguration = await ConfigureLogging(cancellationToken).ConfigureAwait(false);
800 
801  var controlPanelConfiguration = await ConfigureControlPanel(cancellationToken).ConfigureAwait(false);
802 
803  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
804 
805  await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, newGeneralConfiguration, fileLoggingConfiguration, controlPanelConfiguration, cancellationToken).ConfigureAwait(false);
806  }
807 
813  async Task CheckRunWizard(CancellationToken cancellationToken)
814  {
815  var setupWizardMode = generalConfiguration.SetupWizardMode;
816  if (setupWizardMode == SetupWizardMode.Never)
817  return;
818 
819  var forceRun = setupWizardMode == SetupWizardMode.Force || setupWizardMode == SetupWizardMode.Only;
820  if (!console.Available)
821  {
822  if (forceRun)
823  throw new InvalidOperationException("Asked to run setup wizard with no console avaliable!");
824  return;
825  }
826 
827  var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName);
828 
829  async Task HandleSetupCancel()
830  {
831  await console.WriteAsync(String.Empty, true, default).ConfigureAwait(false);
832  await console.WriteAsync("Aborting setup!", true, default).ConfigureAwait(false);
833  }
834 
835  // Link passed cancellationToken with cancel key press
836  Task finalTask = Task.CompletedTask;
837  using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, console.CancelKeyPress))
838  using ((cancellationToken = cts.Token).Register(() => finalTask = HandleSetupCancel()))
839  try
840  {
841  var exists = await ioManager.FileExists(userConfigFileName, cancellationToken).ConfigureAwait(false);
842 
843  bool shouldRunBasedOnAutodetect;
844  if (exists)
845  {
846  var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken).ConfigureAwait(false);
847  var contents = Encoding.UTF8.GetString(bytes);
848  var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents) || contents.Trim() == "{}";
849  shouldRunBasedOnAutodetect = existingConfigIsEmpty;
850  }
851  else
852  shouldRunBasedOnAutodetect = true;
853 
854  if (!shouldRunBasedOnAutodetect)
855  {
856  if (forceRun)
857  {
858  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "The configuration settings are requesting the setup wizard be run, but you already appear to have a configuration file ({0})!", userConfigFileName), true, cancellationToken).ConfigureAwait(false);
859 
860  forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken).ConfigureAwait(false);
861  }
862 
863  if (!forceRun)
864  return;
865  }
866 
867  // flush the logs to prevent console conflicts
868  await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
869 
870  await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false);
871  }
872  finally
873  {
874  await finalTask.ConfigureAwait(false);
875  }
876  }
877 
879  public async Task StartAsync(CancellationToken cancellationToken)
880  {
881  await CheckRunWizard(cancellationToken).ConfigureAwait(false);
882  applicationLifetime.StopApplication();
883  }
884 
886  public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
887  }
888 }
List< string > AllowedOrigins
Origins allowed for CORS requests
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the SetupWizard
Definition: SetupWizard.cs:63
static readonly Version CurrentConfigVersion
The current ConfigVersion.
readonly IIOManager ioManager
The IIOManager for the SetupWizard
Definition: SetupWizard.cs:33
readonly GeneralConfiguration generalConfiguration
The GeneralConfiguration for the SetupWizard
Definition: SetupWizard.cs:73
async Task TestDatabaseConnection(DbConnection testConnection, DatabaseConfiguration databaseConfiguration, string databaseName, bool dbExists, CancellationToken cancellationToken)
Ensure a given testConnection works.
Definition: SetupWizard.cs:168
SetupWizardMode
Determines if the Setup.SetupWizard will run
Abstraction for global::System.Console
Definition: IConsole.cs:9
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the GeneralConfiguration res...
async Task< string > ValidateNonExistantSqliteDBName(string databaseName, CancellationToken cancellationToken)
Definition: SetupWizard.cs:249
async Task StartAsync(CancellationToken cancellationToken)
Definition: SetupWizard.cs:879
async Task< GeneralConfiguration > ConfigureGeneral(CancellationToken cancellationToken)
Prompts the user to create a GeneralConfiguration
Definition: SetupWizard.cs:568
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the DatabaseConfiguration re...
DatabaseType DatabaseType
The Configuration.DatabaseType to create
async Task< DatabaseType > PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
Prompt the user for the DatabaseType.
Definition: SetupWizard.cs:296
async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
Runs the SetupWizard
Definition: SetupWizard.cs:787
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the ControlPanelConfiguratio...
async Task< ushort?> PromptForHostingPort(CancellationToken cancellationToken)
Prompts the user to enter the port to host TGS on
Definition: SetupWizard.cs:136
async Task< DatabaseConfiguration > ConfigureDatabase(CancellationToken cancellationToken)
Prompts the user to create a DatabaseConfiguration
Definition: SetupWizard.cs:356
string ServerVersion
The string form of the global::System.Version of the target server
uint MinimumPasswordLength
Minimum length of database user passwords.
readonly IHostApplicationLifetime applicationLifetime
The IHostApplicationLifetime for the SetupWizard.
Definition: SetupWizard.cs:68
const ushort DefaultApiPort
The default value of ApiPort.
async Task< ControlPanelConfiguration > ConfigureControlPanel(CancellationToken cancellationToken)
Prompts the user to create a ControlPanelConfiguration
Definition: SetupWizard.cs:709
SetupWizard(IIOManager ioManager, IConsole console, IHostEnvironment hostingEnvironment, IAssemblyInformationProvider assemblyInformationProvider, IDatabaseConnectionFactory dbConnectionFactory, IPlatformIdentifier platformIdentifier, IAsyncDelayer asyncDelayer, IHostApplicationLifetime applicationLifetime, IOptions< GeneralConfiguration > generalConfigurationOptions)
Construct a SetupWizard
Definition: SetupWizard.cs:87
async Task< bool > PromptYesNo(string question, CancellationToken cancellationToken)
A prompt for a yes or no value
Definition: SetupWizard.cs:115
readonly IConsole console
The IConsole for the SetupWizard
Definition: SetupWizard.cs:38
async Task CheckRunWizard(CancellationToken cancellationToken)
Check if it should and run the SetupWizard if necessary.
Definition: SetupWizard.cs:813
Configuration options for the Database.DatabaseContext
readonly IPlatformIdentifier platformIdentifier
The IPlatformIdentifier for the SetupWizard
Definition: SetupWizard.cs:58
async Task< FileLoggingConfiguration > ConfigureLogging(CancellationToken cancellationToken)
Prompts the user to create a FileLoggingConfiguration
Definition: SetupWizard.cs:624
DatabaseType
Type of database to user
Definition: DatabaseType.cs:6
readonly IAssemblyInformationProvider assemblyInformationProvider
The IAssemblyInformationProvider for the SetupWizard
Definition: SetupWizard.cs:48
async Task SaveConfiguration(string userConfigFileName, ushort?hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration newGeneralConfiguration, FileLoggingConfiguration fileLoggingConfiguration, ControlPanelConfiguration controlPanelConfiguration, CancellationToken cancellationToken)
Saves a given Configuration set to userConfigFileName
Definition: SetupWizard.cs:742
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the FileLoggingConfiguration...
Interface for using filesystems
Definition: IIOManager.cs:11
readonly IHostEnvironment hostingEnvironment
The IHostEnvironment for the SetupWizard
Definition: SetupWizard.cs:43
Version ConfigVersion
The Version the file says it is.
readonly IDatabaseConnectionFactory dbConnectionFactory
The IDatabaseConnectionFactory for the SetupWizard
Definition: SetupWizard.cs:53
For identifying the current platform