tgstation-server  4.3.2
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  {
185  await console.WriteAsync("Checking MySQL/MariaDB version...", true, cancellationToken).ConfigureAwait(false);
186  using var command = testConnection.CreateCommand();
187  command.CommandText = "SELECT VERSION()";
188  var fullVersion = (string)await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
189  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion), true, cancellationToken).ConfigureAwait(false);
190  var splits = fullVersion.Split('-');
191  databaseConfiguration.MySqlServerVersion = splits.First();
192  }
193 
194  if (!isSqliteDB && !dbExists)
195  {
196  await console.WriteAsync("Testing create DB permission...", true, cancellationToken).ConfigureAwait(false);
197  using (var command = testConnection.CreateCommand())
198  {
199  // I really don't care about user sanitization here, they want to fuck their own DB? so be it
200 #pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
201  command.CommandText = $"CREATE DATABASE {databaseName}";
202 #pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
203  await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
204  }
205 
206  await console.WriteAsync("Success!", true, cancellationToken).ConfigureAwait(false);
207  await console.WriteAsync("Dropping test database...", true, cancellationToken).ConfigureAwait(false);
208  using (var command = testConnection.CreateCommand())
209  {
210 #pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
211  command.CommandText = $"DROP DATABASE {databaseName}";
212 #pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
213  try
214  {
215  await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
216  }
217  catch (OperationCanceledException)
218  {
219  throw;
220  }
221  catch (Exception e)
222  {
223  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
224  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
225  await console.WriteAsync("This should be okay, but you may want to manually drop the database before continuing!", true, cancellationToken).ConfigureAwait(false);
226  await console.WriteAsync("Press any key to continue...", true, cancellationToken).ConfigureAwait(false);
227  await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false);
228  }
229  }
230  }
231  }
232 
233  if (isSqliteDB && !dbExists)
234  await Task.WhenAll(
235  console.WriteAsync("Deleting test database file...", true, cancellationToken),
236  ioManager.DeleteFile(databaseName, cancellationToken)).ConfigureAwait(false);
237  }
238 
239  async Task<string> ValidateNonExistantSqliteDBName(string databaseName, CancellationToken cancellationToken)
240  {
241  var resolvedPath = ioManager.ResolvePath(databaseName);
242  try
243  {
244  var directoryName = ioManager.GetDirectoryName(resolvedPath);
245  bool directoryExisted = await ioManager.DirectoryExists(directoryName, cancellationToken).ConfigureAwait(false);
246  await ioManager.CreateDirectory(directoryName, cancellationToken).ConfigureAwait(false);
247  try
248  {
249  await ioManager.WriteAllBytes(resolvedPath, Array.Empty<byte>(), cancellationToken).ConfigureAwait(false);
250  }
251  catch
252  {
253  if (!directoryExisted)
254  await ioManager.DeleteDirectory(directoryName, cancellationToken).ConfigureAwait(false);
255  throw;
256  }
257  }
258  catch (IOException)
259  {
260  return null;
261  }
262 
263  if (!Path.IsPathRooted(databaseName))
264  {
265  await console.WriteAsync("Note, this relative path (currently) resolves to the following:", true, cancellationToken).ConfigureAwait(false);
266  await console.WriteAsync(resolvedPath, true, cancellationToken).ConfigureAwait(false);
267  bool writeResolved = await PromptYesNo(
268  "Would you like to save the relative path in the configuration? If not, the full path will be saved. (y/n): ",
269  cancellationToken)
270  .ConfigureAwait(false);
271 
272  if (writeResolved)
273  databaseName = resolvedPath;
274  }
275 
276  await ioManager.DeleteFile(databaseName, cancellationToken).ConfigureAwait(false);
277  return databaseName;
278  }
279 
286  async Task<DatabaseType> PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
287  {
288  if (firstTime)
289  {
290  await console.WriteAsync(String.Empty, true, cancellationToken).ConfigureAwait(false);
291  await console.WriteAsync(
292  "NOTE: It is HIGHLY reccommended that TGS runs on a complete relational database, specfically *NOT* Sqlite.",
293  true,
294  cancellationToken)
295  .ConfigureAwait(false);
296  await console.WriteAsync(
297  "Sqlite, by nature cannot perform several DDL operations. Because of this future compatiblility cannot be guaranteed.",
298  true,
299  cancellationToken)
300  .ConfigureAwait(false);
301  await console.WriteAsync(
302  "This means that you may not be able to update to the next minor version of TGS4 without a clean re-installation!",
303  true,
304  cancellationToken)
305  .ConfigureAwait(false);
306  await console.WriteAsync(
307  "Please consider taking the time to set up a relational database if this is meant to be a long-standing server.",
308  true,
309  cancellationToken)
310  .ConfigureAwait(false);
311  await console.WriteAsync(String.Empty, true, cancellationToken).ConfigureAwait(false);
312 
313  await asyncDelayer.Delay(TimeSpan.FromSeconds(3), cancellationToken).ConfigureAwait(false);
314  }
315 
316  await console.WriteAsync("What SQL database type will you be using?", true, cancellationToken).ConfigureAwait(false);
317  do
318  {
319  await console.WriteAsync(
320  String.Format(
321  CultureInfo.InvariantCulture,
322  "Please enter one of {0}, {1}, {2}, or {3}: ",
323  DatabaseType.MariaDB,
324  DatabaseType.MySql,
325 #pragma warning disable SA1515 // Single-line comment should be preceded by blank line
326  // DatabaseType.PostgresSql,
327  DatabaseType.SqlServer,
328 #pragma warning restore SA1515 // Single-line comment should be preceded by blank line
329  DatabaseType.Sqlite),
330  false,
331  cancellationToken)
332  .ConfigureAwait(false);
333  var databaseTypeString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
334  if (Enum.TryParse<DatabaseType>(databaseTypeString, out var databaseType))
335  return databaseType;
336 
337  await console.WriteAsync("Invalid database type!", true, cancellationToken).ConfigureAwait(false);
338  }
339  while (true);
340  }
341 
347  #pragma warning disable CA1502 // TODO: Decomplexify
348  async Task<DatabaseConfiguration> ConfigureDatabase(CancellationToken cancellationToken)
349  {
350  bool firstTime = true;
351  do
352  {
353  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
354 
355  var databaseConfiguration = new DatabaseConfiguration
356  {
357  DatabaseType = await PromptDatabaseType(firstTime, cancellationToken).ConfigureAwait(false)
358  };
359  firstTime = false;
360 
361  string serverAddress = null;
362  ushort? serverPort = null;
363 
364  bool isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
365  if (!isSqliteDB)
366  do
367  {
368  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
369  await console.WriteAsync("Enter the server's address and port [<server>:<port> or <server>] (blank for local): ", false, cancellationToken).ConfigureAwait(false);
370  serverAddress = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
371  if (String.IsNullOrWhiteSpace(serverAddress))
372  serverAddress = null;
373  else if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer)
374  {
375  var match = Regex.Match(serverAddress, @"^(?<server>.+):(?<port>.+)$");
376  if (match.Success)
377  {
378  serverAddress = match.Groups["server"].Value;
379  var portString = match.Groups["port"].Value;
380  if (UInt16.TryParse(portString, out var port))
381  serverPort = port;
382  else
383  {
384  await console.WriteAsync($"Failed to parse port \"{portString}\", please try again.", true, cancellationToken).ConfigureAwait(false);
385  continue;
386  }
387  }
388  }
389 
390  break;
391  }
392  while (true);
393 
394  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
395  await console.WriteAsync($"Enter the database {(isSqliteDB ? "file path" : "name")} (Can be from previous installation. Otherwise, should not exist): ", false, cancellationToken).ConfigureAwait(false);
396 
397  string databaseName;
398  bool dbExists = false;
399  do
400  {
401  databaseName = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
402  if (!String.IsNullOrWhiteSpace(databaseName))
403  {
404  if (isSqliteDB)
405  {
406  dbExists = await ioManager.FileExists(databaseName, cancellationToken).ConfigureAwait(false);
407  if (!dbExists)
408  databaseName = await ValidateNonExistantSqliteDBName(databaseName, cancellationToken).ConfigureAwait(false);
409  }
410  else
411  dbExists = await PromptYesNo("Does this database already exist? If not, we will attempt to CREATE it. (y/n): ", cancellationToken).ConfigureAwait(false);
412  }
413 
414  if (String.IsNullOrWhiteSpace(databaseName))
415  await console.WriteAsync("Invalid database name!", true, cancellationToken).ConfigureAwait(false);
416  else
417  break;
418  }
419  while (true);
420 
421  bool useWinAuth;
422  if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && platformIdentifier.IsWindows)
423  useWinAuth = await PromptYesNo("Use Windows Authentication? (y/n): ", cancellationToken).ConfigureAwait(false);
424  else
425  useWinAuth = false;
426 
427  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
428 
429  string username = null;
430  string password = null;
431  if (!isSqliteDB)
432  if (!useWinAuth)
433  {
434  await console.WriteAsync("Enter username: ", false, cancellationToken).ConfigureAwait(false);
435  username = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
436  await console.WriteAsync("Enter password: ", false, cancellationToken).ConfigureAwait(false);
437  password = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false);
438  }
439  else
440  {
441  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);
442  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);
443  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);
444  }
445 
446  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
447 
448  DbConnection testConnection;
449  void CreateTestConnection(string connectionString) =>
450  testConnection = dbConnectionFactory.CreateConnection(
451  connectionString,
452  databaseConfiguration.DatabaseType);
453 
454  switch (databaseConfiguration.DatabaseType)
455  {
456  case DatabaseType.SqlServer:
457  {
458  var csb = new SqlConnectionStringBuilder
459  {
460  ApplicationName = assemblyInformationProvider.VersionPrefix,
461  DataSource = serverAddress ?? "(local)"
462  };
463 
464  if (useWinAuth)
465  csb.IntegratedSecurity = true;
466  else
467  {
468  csb.UserID = username;
469  csb.Password = password;
470  }
471 
472  CreateTestConnection(csb.ConnectionString);
473  csb.InitialCatalog = databaseName;
474  databaseConfiguration.ConnectionString = csb.ConnectionString;
475  }
476 
477  break;
478  case DatabaseType.MariaDB:
479  case DatabaseType.MySql:
480  {
481  // MySQL/MariaDB
482  var csb = new MySqlConnectionStringBuilder
483  {
484  Server = serverAddress ?? "127.0.0.1",
485  UserID = username,
486  Password = password
487  };
488 
489  if (serverPort.HasValue)
490  csb.Port = serverPort.Value;
491 
492  CreateTestConnection(csb.ConnectionString);
493  csb.Database = databaseName;
494  databaseConfiguration.ConnectionString = csb.ConnectionString;
495  }
496 
497  break;
498  case DatabaseType.Sqlite:
499  {
500  var csb = new SqliteConnectionStringBuilder
501  {
502  DataSource = databaseName,
503  Mode = dbExists ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate
504  };
505 
506  CreateTestConnection(csb.ConnectionString);
507  databaseConfiguration.ConnectionString = csb.ConnectionString;
508  }
509 
510  break;
511  case DatabaseType.PostgresSql:
512  {
513  var csb = new NpgsqlConnectionStringBuilder
514  {
515  ApplicationName = assemblyInformationProvider.VersionPrefix,
516  Host = serverAddress ?? "127.0.0.1",
517  Password = password,
518  Username = username
519  };
520 
521  if (serverPort.HasValue)
522  csb.Port = serverPort.Value;
523 
524  CreateTestConnection(csb.ConnectionString);
525  csb.Database = databaseName;
526  databaseConfiguration.ConnectionString = csb.ConnectionString;
527  }
528 
529  break;
530  default:
531  throw new InvalidOperationException("Invalid DatabaseType!");
532  }
533 
534  try
535  {
536  await TestDatabaseConnection(testConnection, databaseConfiguration, databaseName, dbExists, cancellationToken).ConfigureAwait(false);
537 
538  return databaseConfiguration;
539  }
540  catch (OperationCanceledException)
541  {
542  throw;
543  }
544  catch (Exception e)
545  {
546  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
547  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
548  await console.WriteAsync("Retrying database configuration...", true, cancellationToken).ConfigureAwait(false);
549  }
550  }
551  while (true);
552  }
553  #pragma warning restore CA1502
554 
560  async Task<GeneralConfiguration> ConfigureGeneral(CancellationToken cancellationToken)
561  {
562  var newGeneralConfiguration = new GeneralConfiguration
563  {
565  };
566 
567  do
568  {
569  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
570  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", newGeneralConfiguration.MinimumPasswordLength), false, cancellationToken).ConfigureAwait(false);
571  var passwordLengthString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
572  if (String.IsNullOrWhiteSpace(passwordLengthString))
573  break;
574  if (UInt32.TryParse(passwordLengthString, out var passwordLength) && passwordLength >= 0)
575  {
576  newGeneralConfiguration.MinimumPasswordLength = passwordLength;
577  break;
578  }
579 
580  await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false);
581  }
582  while (true);
583 
584  do
585  {
586  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
587  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Timeout for sending and receiving BYOND topics (ms, 0 for infinite, leave blank for default of {0}): ", newGeneralConfiguration.ByondTopicTimeout), false, cancellationToken).ConfigureAwait(false);
588  var topicTimeoutString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
589  if (String.IsNullOrWhiteSpace(topicTimeoutString))
590  break;
591  if (Int32.TryParse(topicTimeoutString, out var topicTimeout) && topicTimeout >= 0)
592  {
593  newGeneralConfiguration.ByondTopicTimeout = topicTimeout;
594  break;
595  }
596 
597  await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false);
598  }
599  while (true);
600 
601  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
602  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);
603  await console.WriteAsync("GitHub personal access token: ", false, cancellationToken).ConfigureAwait(false);
604  newGeneralConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false);
605  if (String.IsNullOrWhiteSpace(newGeneralConfiguration.GitHubAccessToken))
606  newGeneralConfiguration.GitHubAccessToken = null;
607 
608  // newGeneralConfiguration.UseExperimentalWatchdog = await PromptYesNo("Use the experimental watchdog (NOT RECOMMENDED)? (y/n): ", cancellationToken).ConfigureAwait(false);
609  return newGeneralConfiguration;
610  }
611 
617  async Task<FileLoggingConfiguration> ConfigureLogging(CancellationToken cancellationToken)
618  {
619  var fileLoggingConfiguration = new FileLoggingConfiguration();
620  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
621  fileLoggingConfiguration.Disable = !await PromptYesNo("Enable file logging? (y/n): ", cancellationToken).ConfigureAwait(false);
622 
623  if (!fileLoggingConfiguration.Disable)
624  {
625  do
626  {
627  await console.WriteAsync("Log file directory path (leave blank for default): ", false, cancellationToken).ConfigureAwait(false);
628  fileLoggingConfiguration.Directory = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
629  if (String.IsNullOrWhiteSpace(fileLoggingConfiguration.Directory))
630  {
631  fileLoggingConfiguration.Directory = null;
632  break;
633  }
634 
635  // test a write of it
636  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
637  await console.WriteAsync("Testing directory access...", true, cancellationToken).ConfigureAwait(false);
638  try
639  {
640  await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken).ConfigureAwait(false);
641  var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid()));
642  await ioManager.WriteAllBytes(testFile, Array.Empty<byte>(), cancellationToken).ConfigureAwait(false);
643  try
644  {
645  await ioManager.DeleteFile(testFile, cancellationToken).ConfigureAwait(false);
646  }
647  catch (OperationCanceledException)
648  {
649  throw;
650  }
651  catch (Exception e)
652  {
653  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken).ConfigureAwait(false);
654  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
655  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
656  }
657 
658  break;
659  }
660  catch (OperationCanceledException)
661  {
662  throw;
663  }
664  catch (Exception e)
665  {
666  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
667  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
668  await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken).ConfigureAwait(false);
669  }
670  }
671  while (true);
672 
673  async Task<LogLevel?> PromptLogLevel(string question)
674  {
675  do
676  {
677  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
678  await console.WriteAsync(question, true, cancellationToken).ConfigureAwait(false);
679  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);
680  var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
681  if (String.IsNullOrWhiteSpace(responseString))
682  return null;
683  if (Enum.TryParse<LogLevel>(responseString, out var logLevel) && logLevel != LogLevel.None)
684  return logLevel;
685  await console.WriteAsync("Invalid log level!", true, cancellationToken).ConfigureAwait(false);
686  }
687  while (true);
688  }
689 
690  fileLoggingConfiguration.LogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for normal logs (default {0}).", fileLoggingConfiguration.LogLevel)).ConfigureAwait(false) ?? fileLoggingConfiguration.LogLevel;
691  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;
692  }
693 
694  return fileLoggingConfiguration;
695  }
696 
702  async Task<ControlPanelConfiguration> ConfigureControlPanel(CancellationToken cancellationToken)
703  {
704  var config = new ControlPanelConfiguration
705  {
706  Enable = await PromptYesNo("Enable the web control panel (Incomplete)? (y/n): ", cancellationToken).ConfigureAwait(false),
707  AllowAnyOrigin = await PromptYesNo("Allow web control panels hosted elsewhere to access the server? (Access-Control-Allow-Origin: *) (y/n): ", cancellationToken).ConfigureAwait(false)
708  };
709 
710  if (!config.AllowAnyOrigin)
711  {
712  await console.WriteAsync("Enter a comma seperated list of CORS allowed origins (optional): ", false, cancellationToken).ConfigureAwait(false);
713  var commaSeperatedOrigins = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
714  if (!String.IsNullOrWhiteSpace(commaSeperatedOrigins))
715  {
716  var splits = commaSeperatedOrigins.Split(',');
717  config.AllowedOrigins = new List<string>(splits.Select(x => x.Trim()));
718  }
719  }
720 
721  return config;
722  }
723 
735  async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration newGeneralConfiguration, FileLoggingConfiguration fileLoggingConfiguration, ControlPanelConfiguration controlPanelConfiguration, CancellationToken cancellationToken)
736  {
737  await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken).ConfigureAwait(false);
738 
739  newGeneralConfiguration.ApiPort = hostingPort ?? GeneralConfiguration.DefaultApiPort;
740  var map = new Dictionary<string, object>()
741  {
742  { DatabaseConfiguration.Section, databaseConfiguration },
743  { GeneralConfiguration.Section, newGeneralConfiguration },
744  { FileLoggingConfiguration.Section, fileLoggingConfiguration },
745  { ControlPanelConfiguration.Section, controlPanelConfiguration }
746  };
747 
748  var json = JsonConvert.SerializeObject(map, Formatting.Indented);
749  var configBytes = Encoding.UTF8.GetBytes(json);
750 
751  try
752  {
753  await ioManager.WriteAllBytes(userConfigFileName, configBytes, cancellationToken).ConfigureAwait(false);
754  }
755  catch (OperationCanceledException)
756  {
757  throw;
758  }
759  catch (Exception e)
760  {
761  await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
762  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
763  await console.WriteAsync("For your convienence, here's the json we tried to write out:", true, cancellationToken).ConfigureAwait(false);
764  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
765  await console.WriteAsync(json, true, cancellationToken).ConfigureAwait(false);
766  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
767  await console.WriteAsync("Press any key to exit...", true, cancellationToken).ConfigureAwait(false);
768  await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false);
769  throw new OperationCanceledException();
770  }
771  }
772 
779  async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
780  {
781  // welcome message
782  await console.WriteAsync("Welcome to tgstation-server 4!", true, cancellationToken).ConfigureAwait(false);
783  await console.WriteAsync("This wizard will help you configure your server.", true, cancellationToken).ConfigureAwait(false);
784 
785  var hostingPort = await PromptForHostingPort(cancellationToken).ConfigureAwait(false);
786 
787  var databaseConfiguration = await ConfigureDatabase(cancellationToken).ConfigureAwait(false);
788 
789  var newGeneralConfiguration = await ConfigureGeneral(cancellationToken).ConfigureAwait(false);
790 
791  var fileLoggingConfiguration = await ConfigureLogging(cancellationToken).ConfigureAwait(false);
792 
793  var controlPanelConfiguration = await ConfigureControlPanel(cancellationToken).ConfigureAwait(false);
794 
795  await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
796 
797  await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, newGeneralConfiguration, fileLoggingConfiguration, controlPanelConfiguration, cancellationToken).ConfigureAwait(false);
798  }
799 
805  async Task CheckRunWizard(CancellationToken cancellationToken)
806  {
807  var setupWizardMode = generalConfiguration.SetupWizardMode;
808  if (setupWizardMode == SetupWizardMode.Never)
809  return;
810 
811  var forceRun = setupWizardMode == SetupWizardMode.Force || setupWizardMode == SetupWizardMode.Only;
812  if (!console.Available)
813  {
814  if (forceRun)
815  throw new InvalidOperationException("Asked to run setup wizard with no console avaliable!");
816  return;
817  }
818 
819  var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName);
820 
821  async Task HandleSetupCancel()
822  {
823  await console.WriteAsync(String.Empty, true, default).ConfigureAwait(false);
824  await console.WriteAsync("Aborting setup!", true, default).ConfigureAwait(false);
825  }
826 
827  // Link passed cancellationToken with cancel key press
828  Task finalTask = Task.CompletedTask;
829  using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, console.CancelKeyPress))
830  using ((cancellationToken = cts.Token).Register(() => finalTask = HandleSetupCancel()))
831  try
832  {
833  var exists = await ioManager.FileExists(userConfigFileName, cancellationToken).ConfigureAwait(false);
834 
835  bool shouldRunBasedOnAutodetect;
836  if (exists)
837  {
838  var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken).ConfigureAwait(false);
839  var contents = Encoding.UTF8.GetString(bytes);
840  var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents) || contents.Trim() == "{}";
841  shouldRunBasedOnAutodetect = existingConfigIsEmpty;
842  }
843  else
844  shouldRunBasedOnAutodetect = true;
845 
846  if (!shouldRunBasedOnAutodetect)
847  {
848  if (forceRun)
849  {
850  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);
851 
852  forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken).ConfigureAwait(false);
853  }
854 
855  if (!forceRun)
856  return;
857  }
858 
859  // flush the logs to prevent console conflicts
860  await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false);
861 
862  await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false);
863  }
864  finally
865  {
866  await finalTask.ConfigureAwait(false);
867  }
868  }
869 
871  public async Task StartAsync(CancellationToken cancellationToken)
872  {
873  await CheckRunWizard(cancellationToken).ConfigureAwait(false);
874  applicationLifetime.StopApplication();
875  }
876 
878  public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
879  }
880 }
List< string > AllowedOrigins
Origins allowed for CORS requests
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the SetupWizard
Definition: SetupWizard.cs:63
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:239
async Task StartAsync(CancellationToken cancellationToken)
Definition: SetupWizard.cs:871
async Task< GeneralConfiguration > ConfigureGeneral(CancellationToken cancellationToken)
Prompts the user to create a GeneralConfiguration
Definition: SetupWizard.cs:560
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:286
async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
Runs the SetupWizard
Definition: SetupWizard.cs:779
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:348
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:702
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
string MySqlServerVersion
The string form of the global::System.Version of a target MySQL/MariaDB server
async Task CheckRunWizard(CancellationToken cancellationToken)
Check if it should and run the SetupWizard if necessary.
Definition: SetupWizard.cs:805
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:617
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:735
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
readonly IDatabaseConnectionFactory dbConnectionFactory
The IDatabaseConnectionFactory for the SetupWizard
Definition: SetupWizard.cs:53
For identifying the current platform