tgstation-server 6.12.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
SetupWizard.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Generic;
3using System.Data.Common;
4using System.Globalization;
5using System.IO;
6using System.Linq;
7using System.Net;
8using System.Text;
9using System.Text.RegularExpressions;
10using System.Threading;
11using System.Threading.Tasks;
12
13using Microsoft.Data.SqlClient;
14using Microsoft.Data.Sqlite;
15using Microsoft.Extensions.Hosting;
16using Microsoft.Extensions.Logging;
17using Microsoft.Extensions.Options;
18
19using MySqlConnector;
20
21using Npgsql;
22
31
32using YamlDotNet.Serialization;
33
35{
38 {
43
47 readonly IConsole console;
48
52 readonly IHostEnvironment hostingEnvironment;
53
58
63
68
73
77 readonly IHostApplicationLifetime applicationLifetime;
78
83
88
93
111 IHostEnvironment hostingEnvironment,
116 IHostApplicationLifetime applicationLifetime,
118 IOptions<GeneralConfiguration> generalConfigurationOptions,
119 IOptions<InternalConfiguration> internalConfigurationOptions)
120 {
121 this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
122 this.console = console ?? throw new ArgumentNullException(nameof(console));
123 this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment));
124 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
125 this.dbConnectionFactory = dbConnectionFactory ?? throw new ArgumentNullException(nameof(dbConnectionFactory));
126 this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
127 this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
128 this.applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime));
129 this.postSetupServices = postSetupServices ?? throw new ArgumentNullException(nameof(postSetupServices));
130
131 generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
132 internalConfiguration = internalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(internalConfigurationOptions));
133 }
134
136 protected override async Task ExecuteAsync(CancellationToken cancellationToken)
137 {
138 await CheckRunWizard(cancellationToken);
139 applicationLifetime.StopApplication();
140 }
141
149 async ValueTask<bool> PromptYesNo(string question, bool? defaultResponse, CancellationToken cancellationToken)
150 {
151 do
152 {
153 await console.WriteAsync($"{question} ({(defaultResponse == true ? 'Y' : 'y')}/{(defaultResponse == false ? 'N' : 'n')}): ", false, cancellationToken);
154 var responseString = await console.ReadLineAsync(false, cancellationToken);
155 if (responseString.Length == 0)
156 {
157 if (defaultResponse.HasValue)
158 return defaultResponse.Value;
159 }
160 else
161 {
162 var upperResponse = responseString.ToUpperInvariant();
163 if (upperResponse == "Y" || upperResponse == "YES")
164 return true;
165 else if (upperResponse == "N" || upperResponse == "NO")
166 return false;
167 }
168
169 await console.WriteAsync("Invalid response!", true, cancellationToken);
170 }
171 while (true);
172 }
173
179 async ValueTask<ushort?> PromptForHostingPort(CancellationToken cancellationToken)
180 {
181 await console.WriteAsync(null, true, cancellationToken);
182 await console.WriteAsync("What port would you like to connect to TGS on?", true, cancellationToken);
183 await console.WriteAsync("Note: If this is a docker container with the default port already mapped, use the default.", true, cancellationToken);
184
185 do
186 {
187 await console.WriteAsync(
188 $"API Port (leave blank for default of {GeneralConfiguration.DefaultApiPort}): ",
189 false,
190 cancellationToken);
191 var portString = await console.ReadLineAsync(false, cancellationToken);
192 if (String.IsNullOrWhiteSpace(portString))
193 return null;
194 if (UInt16.TryParse(portString, out var port) && port != 0)
195 return port;
196 await console.WriteAsync("Invalid port! Please enter a value between 1 and 65535", true, cancellationToken);
197 }
198 while (true);
199 }
200
210 async ValueTask TestDatabaseConnection(
211 DbConnection testConnection,
212 DatabaseConfiguration databaseConfiguration,
213 string databaseName,
214 bool dbExists,
215 CancellationToken cancellationToken)
216 {
217 bool isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
218 using (testConnection)
219 {
220 await console.WriteAsync("Testing connection...", true, cancellationToken);
221 await testConnection.OpenAsync(cancellationToken);
222 await console.WriteAsync("Connection successful!", true, cancellationToken);
223
224 if (databaseConfiguration.DatabaseType == DatabaseType.MariaDB
225 || databaseConfiguration.DatabaseType == DatabaseType.MySql
226 || databaseConfiguration.DatabaseType == DatabaseType.PostgresSql)
227 {
228 await console.WriteAsync($"Checking {databaseConfiguration.DatabaseType} version...", true, cancellationToken);
229 using var command = testConnection.CreateCommand();
230 command.CommandText = "SELECT VERSION()";
231 var fullVersion = (string?)await command.ExecuteScalarAsync(cancellationToken);
232 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion), true, cancellationToken);
233
234 if (fullVersion == null)
235 throw new InvalidOperationException($"\"{command.CommandText}\" returned null!");
236
237 if (databaseConfiguration.DatabaseType == DatabaseType.PostgresSql)
238 {
239 var splits = fullVersion.Split(' ');
240 databaseConfiguration.ServerVersion = splits[1].TrimEnd(',');
241 }
242 else
243 {
244 var splits = fullVersion.Split('-');
245 databaseConfiguration.ServerVersion = splits.First();
246 }
247 }
248
249 if (!isSqliteDB && !dbExists)
250 {
251 await console.WriteAsync("Testing create DB permission...", true, cancellationToken);
252 using (var command = testConnection.CreateCommand())
253 {
254 // I really don't care about user sanitization here, they want to fuck their own DB? so be it
255#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
256 command.CommandText = $"CREATE DATABASE {databaseName}";
257#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
258 await command.ExecuteNonQueryAsync(cancellationToken);
259 }
260
261 await console.WriteAsync("Success!", true, cancellationToken);
262 await console.WriteAsync("Dropping test database...", true, cancellationToken);
263 using (var command = testConnection.CreateCommand())
264 {
265#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
266 command.CommandText = $"DROP DATABASE {databaseName}";
267#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
268 try
269 {
270 await command.ExecuteNonQueryAsync(cancellationToken);
271 }
272 catch (OperationCanceledException)
273 {
274 throw;
275 }
276 catch (Exception e)
277 {
278 await console.WriteAsync(e.Message, true, cancellationToken);
279 await console.WriteAsync(null, true, cancellationToken);
280 await console.WriteAsync("This should be okay, but you may want to manually drop the database before continuing!", true, cancellationToken);
281 await console.WriteAsync("Press any key to continue...", true, cancellationToken);
282 await console.PressAnyKeyAsync(cancellationToken);
283 }
284 }
285 }
286
287 await testConnection.CloseAsync();
288 }
289
290 if (isSqliteDB && !dbExists)
291 {
292 await console.WriteAsync("Deleting test database file...", true, cancellationToken);
294 SqliteConnection.ClearAllPools();
295 await ioManager.DeleteFile(databaseName, cancellationToken);
296 }
297 }
298
305 async ValueTask<string?> ValidateNonExistantSqliteDBName(string databaseName, CancellationToken cancellationToken)
306 {
307 var dbPathIsRooted = Path.IsPathRooted(databaseName);
308 var resolvedPath = ioManager.ResolvePath(
309 dbPathIsRooted
310 ? databaseName
313 databaseName));
314 try
315 {
316 var directoryName = ioManager.GetDirectoryName(resolvedPath);
317 bool directoryExisted = await ioManager.DirectoryExists(directoryName, cancellationToken);
318 await ioManager.CreateDirectory(directoryName, cancellationToken);
319 try
320 {
321 await ioManager.WriteAllBytes(resolvedPath, Array.Empty<byte>(), cancellationToken);
322 }
323 catch
324 {
325 if (!directoryExisted)
326 await ioManager.DeleteDirectory(directoryName, cancellationToken);
327 throw;
328 }
329 }
330 catch (IOException)
331 {
332 return null;
333 }
334
335 if (!dbPathIsRooted)
336 {
337 await console.WriteAsync("Note, this relative path currently resolves to the following:", true, cancellationToken);
338 await console.WriteAsync(resolvedPath, true, cancellationToken);
339 bool writeResolved = await PromptYesNo(
340 "Would you like to save the relative path in the configuration? If not, the full path will be saved.",
341 null,
342 cancellationToken);
343
344 if (writeResolved)
345 databaseName = resolvedPath;
346 }
347
348 await ioManager.DeleteFile(databaseName, cancellationToken);
349 return databaseName;
350 }
351
358 async ValueTask<DatabaseType> PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
359 {
360 if (firstTime)
361 {
363 {
364 await console.WriteAsync("It looks like you just installed MariaDB. Selecting it as the database type.", true, cancellationToken);
365 return DatabaseType.MariaDB;
366 }
367
368 await console.WriteAsync(String.Empty, true, cancellationToken);
369 await console.WriteAsync(
370 "NOTE: If you are serious about hosting public servers, it is HIGHLY reccommended that TGS runs on a database *OTHER THAN* Sqlite.",
371 true,
372 cancellationToken);
373 await console.WriteAsync(
374 "It is, however, the easiest option to get started with and will pose few if any problems in a single user scenario.",
375 true,
376 cancellationToken);
377 }
378
379 await console.WriteAsync("What SQL database type will you be using?", true, cancellationToken);
380 do
381 {
382 await console.WriteAsync(
383 String.Format(
384 CultureInfo.InvariantCulture,
385 "Please enter one of {0}, {1}, {2}, {3} or {4}: ",
386 DatabaseType.MariaDB,
387 DatabaseType.MySql,
388 DatabaseType.PostgresSql,
389 DatabaseType.SqlServer,
390 DatabaseType.Sqlite),
391 false,
392 cancellationToken);
393 var databaseTypeString = await console.ReadLineAsync(false, cancellationToken);
394 if (Enum.TryParse<DatabaseType>(databaseTypeString, out var databaseType))
395 return databaseType;
396
397 await console.WriteAsync("Invalid database type!", true, cancellationToken);
398 }
399 while (true);
400 }
401
407#pragma warning disable CA1502 // TODO: Decomplexify
408 async ValueTask<DatabaseConfiguration> ConfigureDatabase(CancellationToken cancellationToken)
409 {
410 bool firstTime = true;
411 do
412 {
413 await console.WriteAsync(null, true, cancellationToken);
414
415 var databaseConfiguration = new DatabaseConfiguration
416 {
417 DatabaseType = await PromptDatabaseType(firstTime, cancellationToken),
418 };
419
420 string? serverAddress = null;
421 ushort? serverPort = null;
422
423 var definitelyLocalMariaDB = firstTime && internalConfiguration.MariaDBSetup;
424 var isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
425 IPHostEntry? serverAddressEntry = null;
426 if (!isSqliteDB)
427 do
428 {
429 await console.WriteAsync(null, true, cancellationToken);
430 if (definitelyLocalMariaDB)
431 {
432 await console.WriteAsync("Enter the server's port (blank for 3306): ", false, cancellationToken);
433 var enteredPort = await console.ReadLineAsync(false, cancellationToken);
434 if (!String.IsNullOrWhiteSpace(enteredPort) && enteredPort.Trim() != "3306")
435 serverAddress = $"localhost:{enteredPort}";
436 }
437 else
438 {
439 await console.WriteAsync("Enter the server's address and port [<server>:<port> or <server>] (blank for local): ", false, cancellationToken);
440 serverAddress = await console.ReadLineAsync(false, cancellationToken);
441 }
442
443 if (String.IsNullOrWhiteSpace(serverAddress))
444 serverAddress = null;
445 else if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer)
446 {
447 var match = Regex.Match(serverAddress, @"^(?<server>.+):(?<port>.+)$");
448 if (match.Success)
449 {
450 serverAddress = match.Groups["server"].Value;
451 var portString = match.Groups["port"].Value;
452 if (UInt16.TryParse(portString, out var port))
453 serverPort = port;
454 else
455 {
456 await console.WriteAsync($"Failed to parse port \"{portString}\", please try again.", true, cancellationToken);
457 continue;
458 }
459 }
460 }
461
462 try
463 {
464 if (serverAddress != null)
465 {
466 await console.WriteAsync("Attempting to resolve address...", true, cancellationToken);
467 serverAddressEntry = await Dns.GetHostEntryAsync(serverAddress, cancellationToken);
468 }
469
470 break;
471 }
472 catch (Exception ex)
473 {
474 await console.WriteAsync($"Unable to resolve address: {ex.Message}", true, cancellationToken);
475 }
476 }
477 while (true);
478
479 await console.WriteAsync(null, true, cancellationToken);
480 await console.WriteAsync($"Enter the database {(isSqliteDB ? "file path" : "name")} ({(definitelyLocalMariaDB ? "leave blank for \"tgs\")" : "Can be from previous installation. Otherwise, should not exist")}): ", false, cancellationToken);
481
482 string? databaseName;
483 bool dbExists = false;
484 do
485 {
486 databaseName = await console.ReadLineAsync(false, cancellationToken);
487 if (!String.IsNullOrWhiteSpace(databaseName))
488 {
489 if (isSqliteDB)
490 {
491 dbExists = await ioManager.FileExists(databaseName, cancellationToken);
492 if (!dbExists)
493 databaseName = await ValidateNonExistantSqliteDBName(databaseName, cancellationToken);
494 }
495 else
496 dbExists = await PromptYesNo(
497 "Does this database already exist? If not, we will attempt to CREATE it.",
498 null,
499 cancellationToken);
500 }
501 else if (definitelyLocalMariaDB)
502 databaseName = "tgs";
503
504 if (String.IsNullOrWhiteSpace(databaseName))
505 await console.WriteAsync("Invalid database name!", true, cancellationToken);
506 else
507 break;
508 }
509 while (true);
510
511 var useWinAuth = false;
512 var encrypt = false;
513 if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && platformIdentifier.IsWindows)
514 {
515 var defaultResponse = serverAddressEntry?.AddressList.Any(IPAddress.IsLoopback) ?? false
516 ? (bool?)true
517 : null;
518 useWinAuth = await PromptYesNo("Use Windows Authentication?", defaultResponse, cancellationToken);
519 encrypt = await PromptYesNo("Use encrypted connection?", false, cancellationToken);
520 }
521
522 await console.WriteAsync(null, true, cancellationToken);
523
524 string? username = null;
525 string? password = null;
526 if (!isSqliteDB)
527 if (!useWinAuth)
528 {
529 if (definitelyLocalMariaDB)
530 {
531 await console.WriteAsync("Using username: root", true, cancellationToken);
532 username = "root";
533 }
534 else
535 {
536 await console.WriteAsync("Enter username: ", false, cancellationToken);
537 username = await console.ReadLineAsync(false, cancellationToken);
538 }
539
540 await console.WriteAsync("Enter password: ", false, cancellationToken);
541 password = await console.ReadLineAsync(true, cancellationToken);
542 }
543 else
544 {
545 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);
546 await console.WriteAsync("The account it uses in MSSQL is usually \"NT AUTHORITY\\SYSTEM\" and the role it needs is usually \"dbcreator\".", true, cancellationToken);
547 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);
548 }
549
550 await console.WriteAsync(null, true, cancellationToken);
551
552 DbConnection testConnection;
553 void CreateTestConnection(string connectionString) =>
554 testConnection = dbConnectionFactory.CreateConnection(
555 connectionString,
556 databaseConfiguration.DatabaseType);
557
558 switch (databaseConfiguration.DatabaseType)
559 {
560 case DatabaseType.SqlServer:
561 {
562 var csb = new SqlConnectionStringBuilder
563 {
564 ApplicationName = assemblyInformationProvider.VersionPrefix,
565 DataSource = serverAddress ?? "(local)",
566 Encrypt = encrypt,
567 };
568
569 if (useWinAuth)
570 csb.IntegratedSecurity = true;
571 else
572 {
573 csb.UserID = username;
574 csb.Password = password;
575 }
576
577 csb.Encrypt = encrypt;
578
579 CreateTestConnection(csb.ConnectionString);
580 csb.InitialCatalog = databaseName;
581 databaseConfiguration.ConnectionString = csb.ConnectionString;
582 }
583
584 break;
585 case DatabaseType.MariaDB:
586 case DatabaseType.MySql:
587 {
588 // MySQL/MariaDB
589 var csb = new MySqlConnectionStringBuilder
590 {
591 Server = serverAddress ?? "127.0.0.1",
592 UserID = username,
593 Password = password,
594 };
595
596 if (serverPort.HasValue)
597 csb.Port = serverPort.Value;
598
599 CreateTestConnection(csb.ConnectionString);
600 csb.Database = databaseName;
601 databaseConfiguration.ConnectionString = csb.ConnectionString;
602 }
603
604 break;
605 case DatabaseType.Sqlite:
606 {
607 var csb = new SqliteConnectionStringBuilder
608 {
609 DataSource = databaseName,
610 Mode = dbExists ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate,
611 };
612
613 CreateTestConnection(csb.ConnectionString);
614
615 csb.Mode = SqliteOpenMode.ReadWriteCreate;
616 databaseConfiguration.ConnectionString = csb.ConnectionString;
617 }
618
619 break;
620 case DatabaseType.PostgresSql:
621 {
622 var csb = new NpgsqlConnectionStringBuilder
623 {
624 ApplicationName = assemblyInformationProvider.VersionPrefix,
625 Host = serverAddress ?? "127.0.0.1",
626 Password = password,
627 Username = username,
628 };
629
630 if (serverPort.HasValue)
631 csb.Port = serverPort.Value;
632
633 CreateTestConnection(csb.ConnectionString);
634 csb.Database = databaseName;
635 databaseConfiguration.ConnectionString = csb.ConnectionString;
636 }
637
638 break;
639 default:
640 throw new InvalidOperationException("Invalid DatabaseType!");
641 }
642
643 try
644 {
645 await TestDatabaseConnection(testConnection, databaseConfiguration, databaseName, dbExists, cancellationToken);
646
647 return databaseConfiguration;
648 }
649 catch (OperationCanceledException)
650 {
651 throw;
652 }
653 catch (Exception e)
654 {
655 await console.WriteAsync(e.Message, true, cancellationToken);
656 await console.WriteAsync(null, true, cancellationToken);
657 await console.WriteAsync("Retrying database configuration...", true, cancellationToken);
658
659 if (definitelyLocalMariaDB)
660 await console.WriteAsync("No longer assuming MariaDB is the target.", true, cancellationToken);
661
662 firstTime = false;
663 }
664 }
665 while (true);
666 }
667#pragma warning restore CA1502
668
674 async ValueTask<GeneralConfiguration> ConfigureGeneral(CancellationToken cancellationToken)
675 {
676 var newGeneralConfiguration = new GeneralConfiguration
677 {
678 SetupWizardMode = SetupWizardMode.Never,
679 };
680
681 do
682 {
683 await console.WriteAsync(null, true, cancellationToken);
684 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", newGeneralConfiguration.MinimumPasswordLength), false, cancellationToken);
685 var passwordLengthString = await console.ReadLineAsync(false, cancellationToken);
686 if (String.IsNullOrWhiteSpace(passwordLengthString))
687 break;
688 if (UInt32.TryParse(passwordLengthString, out var passwordLength) && passwordLength >= 0)
689 {
690 newGeneralConfiguration.MinimumPasswordLength = passwordLength;
691 break;
692 }
693
694 await console.WriteAsync("Please enter a positive integer!", true, cancellationToken);
695 }
696 while (true);
697
698 do
699 {
700 await console.WriteAsync(null, true, cancellationToken);
701 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);
702 var topicTimeoutString = await console.ReadLineAsync(false, cancellationToken);
703 if (String.IsNullOrWhiteSpace(topicTimeoutString))
704 break;
705 if (UInt32.TryParse(topicTimeoutString, out var topicTimeout) && topicTimeout >= 0)
706 {
707 newGeneralConfiguration.ByondTopicTimeout = topicTimeout;
708 break;
709 }
710
711 await console.WriteAsync("Please enter a positive integer!", true, cancellationToken);
712 }
713 while (true);
714
715 await console.WriteAsync(null, true, cancellationToken);
716 await console.WriteAsync("Enter a classic GitHub personal access token to bypass some rate limits (this is optional and does not require any scopes)", true, cancellationToken);
717 await console.WriteAsync("GitHub personal access token: ", false, cancellationToken);
718 newGeneralConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken);
719 if (String.IsNullOrWhiteSpace(newGeneralConfiguration.GitHubAccessToken))
720 newGeneralConfiguration.GitHubAccessToken = null;
721
722 newGeneralConfiguration.HostApiDocumentation = await PromptYesNo("Host API Documentation?", false, cancellationToken);
723
724 return newGeneralConfiguration;
725 }
726
732 async ValueTask<FileLoggingConfiguration> ConfigureLogging(CancellationToken cancellationToken)
733 {
734 var fileLoggingConfiguration = new FileLoggingConfiguration();
735 await console.WriteAsync(null, true, cancellationToken);
736 fileLoggingConfiguration.Disable = !await PromptYesNo("Enable file logging?", true, cancellationToken);
737
738 if (!fileLoggingConfiguration.Disable)
739 {
740 do
741 {
742 await console.WriteAsync("Log file directory path (leave blank for default): ", false, cancellationToken);
743 fileLoggingConfiguration.Directory = await console.ReadLineAsync(false, cancellationToken);
744 if (String.IsNullOrWhiteSpace(fileLoggingConfiguration.Directory))
745 {
746 fileLoggingConfiguration.Directory = null;
747 break;
748 }
749
750 // test a write of it
751 await console.WriteAsync(null, true, cancellationToken);
752 await console.WriteAsync("Testing directory access...", true, cancellationToken);
753 try
754 {
755 await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken);
756 var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid()));
757 await ioManager.WriteAllBytes(testFile, Array.Empty<byte>(), cancellationToken);
758 try
759 {
760 await ioManager.DeleteFile(testFile, cancellationToken);
761 }
762 catch (OperationCanceledException)
763 {
764 throw;
765 }
766 catch (Exception e)
767 {
768 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken);
769 await console.WriteAsync(e.Message, true, cancellationToken);
770 await console.WriteAsync(null, true, cancellationToken);
771 }
772
773 break;
774 }
775 catch (OperationCanceledException)
776 {
777 throw;
778 }
779 catch (Exception e)
780 {
781 await console.WriteAsync(e.Message, true, cancellationToken);
782 await console.WriteAsync(null, true, cancellationToken);
783 await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken);
784 }
785 }
786 while (true);
787
788 async ValueTask<LogLevel?> PromptLogLevel(string question)
789 {
790 do
791 {
792 await console.WriteAsync(null, true, cancellationToken);
793 await console.WriteAsync(question, true, cancellationToken);
794 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);
795 var responseString = await console.ReadLineAsync(false, cancellationToken);
796 if (String.IsNullOrWhiteSpace(responseString))
797 return null;
798 if (Enum.TryParse<LogLevel>(responseString, out var logLevel) && logLevel != LogLevel.None)
799 return logLevel;
800 await console.WriteAsync("Invalid log level!", true, cancellationToken);
801 }
802 while (true);
803 }
804
805 fileLoggingConfiguration.LogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for normal logs (default {0}).", fileLoggingConfiguration.LogLevel)) ?? fileLoggingConfiguration.LogLevel;
806 fileLoggingConfiguration.MicrosoftLogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for Microsoft logs (VERY verbose, default {0}).", fileLoggingConfiguration.MicrosoftLogLevel)) ?? fileLoggingConfiguration.MicrosoftLogLevel;
807 }
808
809 return fileLoggingConfiguration;
810 }
811
817 async ValueTask<ElasticsearchConfiguration> ConfigureElasticsearch(CancellationToken cancellationToken)
818 {
819 var elasticsearchConfiguration = new ElasticsearchConfiguration();
820 await console.WriteAsync(null, true, cancellationToken);
821 elasticsearchConfiguration.Enable = await PromptYesNo("Enable logging to an external ElasticSearch server?", false, cancellationToken);
822
823 if (elasticsearchConfiguration.Enable)
824 {
825 do
826 {
827 await console.WriteAsync("ElasticSearch server endpoint (Include protocol and port, leave blank for http://127.0.0.1:9200): ", false, cancellationToken);
828 var hostString = await console.ReadLineAsync(false, cancellationToken);
829 if (String.IsNullOrWhiteSpace(hostString))
830 hostString = "http://127.0.0.1:9200";
831
832 if (Uri.TryCreate(hostString, UriKind.Absolute, out var host))
833 {
834 elasticsearchConfiguration.Host = host;
835 break;
836 }
837
838 await console.WriteAsync("Invalid URI!", true, cancellationToken);
839 }
840 while (true);
841
842 do
843 {
844 await console.WriteAsync("Enter Elasticsearch username: ", false, cancellationToken);
845 elasticsearchConfiguration.Username = await console.ReadLineAsync(false, cancellationToken);
846 if (!String.IsNullOrWhiteSpace(elasticsearchConfiguration.Username))
847 break;
848 }
849 while (true);
850
851 do
852 {
853 await console.WriteAsync("Enter password: ", false, cancellationToken);
854 elasticsearchConfiguration.Password = await console.ReadLineAsync(true, cancellationToken);
855 if (!String.IsNullOrWhiteSpace(elasticsearchConfiguration.Username))
856 break;
857 }
858 while (true);
859 }
860
861 return elasticsearchConfiguration;
862 }
863
869 async ValueTask<ControlPanelConfiguration> ConfigureControlPanel(CancellationToken cancellationToken)
870 {
871 var config = new ControlPanelConfiguration
872 {
873 Enable = await PromptYesNo("Enable the web control panel?", true, cancellationToken),
874 AllowAnyOrigin = await PromptYesNo(
875 "Allow web control panels hosted elsewhere to access the server? (Access-Control-Allow-Origin: *)",
876 true,
877 cancellationToken),
878 };
879
880 if (!config.AllowAnyOrigin)
881 {
882 await console.WriteAsync("Enter a comma seperated list of CORS allowed origins (optional): ", false, cancellationToken);
883 var commaSeperatedOrigins = await console.ReadLineAsync(false, cancellationToken);
884 if (!String.IsNullOrWhiteSpace(commaSeperatedOrigins))
885 {
886 var splits = commaSeperatedOrigins.Split(',');
887 config.AllowedOrigins = new List<string>(splits.Select(x => x.Trim()));
888 }
889 }
890
891 return config;
892 }
893
899 async ValueTask<SwarmConfiguration?> ConfigureSwarm(CancellationToken cancellationToken)
900 {
901 var enable = await PromptYesNo("Enable swarm mode?", false, cancellationToken);
902 if (!enable)
903 return null;
904
905 string identifer;
906 do
907 {
908 await console.WriteAsync("Enter this server's identifer: ", false, cancellationToken);
909 identifer = await console.ReadLineAsync(false, cancellationToken);
910 }
911 while (String.IsNullOrWhiteSpace(identifer));
912
913 async ValueTask<Uri> ParseAddress(string question)
914 {
915 var first = true;
916 Uri? address;
917 do
918 {
919 if (first)
920 first = false;
921 else
922 await console.WriteAsync("Invalid address!", true, cancellationToken);
923
924 await console.WriteAsync(question, false, cancellationToken);
925 var addressString = await console.ReadLineAsync(false, cancellationToken);
926 if (Uri.TryCreate(addressString, UriKind.Absolute, out address)
927 && address.Scheme != Uri.UriSchemeHttp
928 && address.Scheme != Uri.UriSchemeHttps)
929 address = null;
930 }
931 while (address == null);
932
933 return address;
934 }
935
936 var address = await ParseAddress("Enter this server's INTERNAL http(s) address: ");
937 var publicAddress = await ParseAddress("Enter this server's PUBLIC https(s) address: ");
938 string privateKey;
939 do
940 {
941 await console.WriteAsync("Enter the swarm private key: ", false, cancellationToken);
942 privateKey = await console.ReadLineAsync(false, cancellationToken);
943 }
944 while (String.IsNullOrWhiteSpace(privateKey));
945
946 var controller = await PromptYesNo("Is this server the swarm's controller? (y/n): ", null, cancellationToken);
947 Uri? controllerAddress = null;
948 if (!controller)
949 controllerAddress = await ParseAddress("Enter the swarm controller's HTTP(S) address: ");
950
951 return new SwarmConfiguration
952 {
953 Address = address,
954 PublicAddress = publicAddress,
955 ControllerAddress = controllerAddress,
956 Identifier = identifer,
957 PrivateKey = privateKey,
958 };
959 }
960
966 async ValueTask<TelemetryConfiguration> ConfigureTelemetry(CancellationToken cancellationToken)
967 {
968 bool enableReporting = await PromptYesNo("Enable version telemetry? This anonymously reports the TGS version in use.", true, cancellationToken);
969
970 string? serverFriendlyName = null;
971 if (enableReporting)
972 {
973 await console.WriteAsync("(Optional) Publically associate your reported version with a friendly name:", false, cancellationToken);
974 serverFriendlyName = await console.ReadLineAsync(false, cancellationToken);
975 if (String.IsNullOrWhiteSpace(serverFriendlyName))
976 serverFriendlyName = null;
977 }
978
979 return new TelemetryConfiguration
980 {
981 DisableVersionReporting = !enableReporting,
982 ServerFriendlyName = serverFriendlyName,
983 };
984 }
985
1000 async ValueTask SaveConfiguration(
1001 string userConfigFileName,
1002 ushort? hostingPort,
1003 DatabaseConfiguration databaseConfiguration,
1004 GeneralConfiguration newGeneralConfiguration,
1005 FileLoggingConfiguration? fileLoggingConfiguration,
1006 ElasticsearchConfiguration? elasticsearchConfiguration,
1007 ControlPanelConfiguration controlPanelConfiguration,
1008 SwarmConfiguration? swarmConfiguration,
1009 TelemetryConfiguration? telemetryConfiguration,
1010 CancellationToken cancellationToken)
1011 {
1012 newGeneralConfiguration.ApiPort = hostingPort ?? GeneralConfiguration.DefaultApiPort;
1013 newGeneralConfiguration.ConfigVersion = GeneralConfiguration.CurrentConfigVersion;
1014 var map = new Dictionary<string, object?>()
1015 {
1016 { DatabaseConfiguration.Section, databaseConfiguration },
1017 { GeneralConfiguration.Section, newGeneralConfiguration },
1018 { FileLoggingConfiguration.Section, fileLoggingConfiguration },
1019 { ElasticsearchConfiguration.Section, elasticsearchConfiguration },
1020 { ControlPanelConfiguration.Section, controlPanelConfiguration },
1021 { SwarmConfiguration.Section, swarmConfiguration },
1022 { TelemetryConfiguration.Section, telemetryConfiguration },
1023 };
1024
1025 var versionConverter = new VersionConverter();
1026 var builder = new SerializerBuilder()
1027 .WithTypeConverter(versionConverter);
1028
1029 if (userConfigFileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
1030 builder.JsonCompatible();
1031
1032 var serializer = new SerializerBuilder()
1033 .WithTypeConverter(versionConverter)
1034 .Build();
1035
1036 var serializedYaml = serializer.Serialize(map);
1037
1038 // big hack, but, prevent the default control panel channel from being overridden
1039 serializedYaml = serializedYaml.Replace(
1040 $"\n {nameof(ControlPanelConfiguration.Channel)}: ",
1041 String.Empty,
1042 StringComparison.Ordinal)
1043 .Replace("\r", String.Empty, StringComparison.Ordinal);
1044
1045 var configBytes = Encoding.UTF8.GetBytes(serializedYaml);
1046
1047 try
1048 {
1049 await ioManager.WriteAllBytes(
1050 userConfigFileName,
1051 configBytes,
1052 cancellationToken);
1053
1054 postSetupServices.ReloadRequired = true;
1055 }
1056 catch (Exception e) when (e is not OperationCanceledException)
1057 {
1058 await console.WriteAsync(e.Message, true, cancellationToken);
1059 await console.WriteAsync(null, true, cancellationToken);
1060 await console.WriteAsync("For your convienence, here's the yaml we tried to write out:", true, cancellationToken);
1061 await console.WriteAsync(null, true, cancellationToken);
1062 await console.WriteAsync(serializedYaml, true, cancellationToken);
1063 await console.WriteAsync(null, true, cancellationToken);
1064 await console.WriteAsync("Press any key to exit...", true, cancellationToken);
1065 await console.PressAnyKeyAsync(cancellationToken);
1066 throw new OperationCanceledException();
1067 }
1068 }
1069
1076 async ValueTask RunWizard(string userConfigFileName, CancellationToken cancellationToken)
1077 {
1078 // welcome message
1079 await console.WriteAsync($"Welcome to {Constants.CanonicalPackageName}!", true, cancellationToken);
1080 await console.WriteAsync("This wizard will help you configure your server.", true, cancellationToken);
1081
1082 var hostingPort = await PromptForHostingPort(cancellationToken);
1083
1084 var databaseConfiguration = await ConfigureDatabase(cancellationToken);
1085
1086 var newGeneralConfiguration = await ConfigureGeneral(cancellationToken);
1087
1088 var fileLoggingConfiguration = await ConfigureLogging(cancellationToken);
1089
1090 var elasticSearchConfiguration = await ConfigureElasticsearch(cancellationToken);
1091
1092 var controlPanelConfiguration = await ConfigureControlPanel(cancellationToken);
1093
1094 var swarmConfiguration = await ConfigureSwarm(cancellationToken);
1095
1096 var telemetryConfiguration = await ConfigureTelemetry(cancellationToken);
1097
1098 await console.WriteAsync(null, true, cancellationToken);
1099 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken);
1100
1101 await SaveConfiguration(
1102 userConfigFileName,
1103 hostingPort,
1104 databaseConfiguration,
1105 newGeneralConfiguration,
1106 fileLoggingConfiguration,
1107 elasticSearchConfiguration,
1108 controlPanelConfiguration,
1109 swarmConfiguration,
1110 telemetryConfiguration,
1111 cancellationToken);
1112 }
1113
1119 async ValueTask CheckRunWizard(CancellationToken cancellationToken)
1120 {
1121 var setupWizardMode = generalConfiguration.SetupWizardMode;
1122 if (setupWizardMode == SetupWizardMode.Never)
1123 return;
1124
1125 var forceRun = setupWizardMode == SetupWizardMode.Force || setupWizardMode == SetupWizardMode.Only;
1126 if (!console.Available)
1127 {
1128 if (forceRun)
1129 throw new InvalidOperationException("Asked to run setup wizard with no console avaliable!");
1130 return;
1131 }
1132
1133 var userConfigFileName = ioManager.ConcatPath(
1134 internalConfiguration.AppSettingsBasePath,
1135 $"{ServerFactory.AppSettings}.{hostingEnvironment.EnvironmentName}.yml");
1136
1137 async Task HandleSetupCancel()
1138 {
1139 // DCTx2: Operation should always run
1140 await console.WriteAsync(String.Empty, true, default);
1141 await console.WriteAsync("Aborting setup!", true, default);
1142 }
1143
1144 Task finalTask = Task.CompletedTask;
1145 string? originalConsoleTitle = null;
1146 void SetConsoleTitle()
1147 {
1148 if (originalConsoleTitle != null)
1149 return;
1150
1151 originalConsoleTitle = console.Title;
1152 console.SetTitle($"{assemblyInformationProvider.VersionString} Setup Wizard");
1153 }
1154
1155 // Link passed cancellationToken with cancel key press
1156 using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, console.CancelKeyPress))
1157 using ((cancellationToken = cts.Token).Register(() => finalTask = HandleSetupCancel()))
1158 try
1159 {
1160 var exists = await ioManager.FileExists(userConfigFileName, cancellationToken);
1161 if (!exists)
1162 {
1163 var legacyJsonFileName = $"appsettings.{hostingEnvironment.EnvironmentName}.json";
1164 exists = await ioManager.FileExists(legacyJsonFileName, cancellationToken);
1165 if (exists)
1166 userConfigFileName = legacyJsonFileName;
1167 }
1168
1169 bool shouldRunBasedOnAutodetect;
1170 if (exists)
1171 {
1172 var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken);
1173 var contents = Encoding.UTF8.GetString(bytes);
1174 var lines = contents.Split('\n', StringSplitOptions.RemoveEmptyEntries);
1175 var existingConfigIsEmpty = lines
1176 .Select(line => line.Trim())
1177 .All(line => line[0] == '#' || line == "{}" || line.Length == 0);
1178 shouldRunBasedOnAutodetect = existingConfigIsEmpty;
1179 }
1180 else
1181 shouldRunBasedOnAutodetect = true;
1182
1183 if (!shouldRunBasedOnAutodetect)
1184 {
1185 if (forceRun)
1186 {
1187 SetConsoleTitle();
1188 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);
1189
1190 forceRun = await PromptYesNo("Continue running setup wizard?", false, cancellationToken);
1191 }
1192
1193 if (!forceRun)
1194 return;
1195 }
1196
1197 SetConsoleTitle();
1198
1199 if (!String.IsNullOrEmpty(internalConfiguration.MariaDBDefaultRootPassword))
1200 {
1201 // we can generate the whole thing.
1202 var csb = new MySqlConnectionStringBuilder
1203 {
1204 Server = "127.0.0.1",
1205 UserID = "root",
1206 Password = internalConfiguration.MariaDBDefaultRootPassword,
1207 Database = "tgs",
1208 };
1209
1210 await SaveConfiguration(
1211 userConfigFileName,
1212 null,
1214 {
1215 ConnectionString = csb.ConnectionString,
1216 DatabaseType = DatabaseType.MariaDB,
1218 },
1220 null,
1221 null,
1223 {
1224 Enable = true,
1225 AllowAnyOrigin = true,
1226 },
1227 null,
1228 null,
1229 cancellationToken);
1230 }
1231 else
1232 {
1233 // flush the logs to prevent console conflicts
1234 await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken);
1235
1236 await RunWizard(userConfigFileName, cancellationToken);
1237 }
1238 }
1239 finally
1240 {
1241 await finalTask;
1242 if (originalConsoleTitle != null)
1243 console.SetTitle(originalConsoleTitle);
1244 }
1245 }
1246 }
1247}
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the ControlPanelConfiguratio...
Configuration options for the Database.DatabaseContext.
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the DatabaseConfiguration re...
string? ConnectionString
The connection string for the database.
DatabaseType DatabaseType
The Configuration.DatabaseType to create.
Configuration options pertaining to elasticsearch log storage.
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the ElasticsearchConfigurati...
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the FileLoggingConfiguration...
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the GeneralConfiguration res...
static readonly Version CurrentConfigVersion
The current ConfigVersion.
const ushort DefaultApiPort
The default value of ApiPort.
Unstable configuration options used internally by TGS.
bool MariaDBSetup
Coerce the Setup.SetupWizard to select DatabaseType.MariaDB.
string AppSettingsBasePath
The base path for the app settings configuration files.
Configuration for the server swarm system.
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the SwarmConfiguration resid...
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the TelemetryConfiguration r...
Attribute for bringing in the master versions list from MSBuild that aren't embedded into assemblies ...
string RawMariaDBRedistVersion
The Version string of the MariaDB server bundled with TGS installs.
static MasterVersionsAttribute Instance
Return the Assembly's instance of the MasterVersionsAttribute.
async ValueTask< ControlPanelConfiguration > ConfigureControlPanel(CancellationToken cancellationToken)
Prompts the user to create a ControlPanelConfiguration.
readonly IIOManager ioManager
The IIOManager for the SetupWizard.
readonly InternalConfiguration internalConfiguration
The InternalConfiguration for the SetupWizard.
async ValueTask< ushort?> PromptForHostingPort(CancellationToken cancellationToken)
Prompts the user to enter the port to host TGS on.
readonly IHostEnvironment hostingEnvironment
The IHostEnvironment for the SetupWizard.
async ValueTask CheckRunWizard(CancellationToken cancellationToken)
Check if it should and run the SetupWizard if necessary.
async ValueTask< SwarmConfiguration?> ConfigureSwarm(CancellationToken cancellationToken)
Prompts the user to create a SwarmConfiguration.
readonly GeneralConfiguration generalConfiguration
The GeneralConfiguration for the SetupWizard.
async ValueTask< DatabaseConfiguration > ConfigureDatabase(CancellationToken cancellationToken)
Prompts the user to create a DatabaseConfiguration.
readonly IAssemblyInformationProvider assemblyInformationProvider
The IAssemblyInformationProvider for the SetupWizard.
async ValueTask< TelemetryConfiguration > ConfigureTelemetry(CancellationToken cancellationToken)
Prompts the user to create a TelemetryConfiguration.
readonly IPlatformIdentifier platformIdentifier
The IPlatformIdentifier for the SetupWizard.
readonly IDatabaseConnectionFactory dbConnectionFactory
The IDatabaseConnectionFactory for the SetupWizard.
override async Task ExecuteAsync(CancellationToken cancellationToken)
readonly IConsole console
The IConsole for the SetupWizard.
async ValueTask< bool > PromptYesNo(string question, bool? defaultResponse, CancellationToken cancellationToken)
A prompt for a yes or no value.
readonly IPostSetupServices postSetupServices
The IPostSetupServices for the SetupWizard.
async ValueTask RunWizard(string userConfigFileName, CancellationToken cancellationToken)
Runs the SetupWizard.
readonly IHostApplicationLifetime applicationLifetime
The IHostApplicationLifetime for the SetupWizard.
async ValueTask< DatabaseType > PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
Prompt the user for the DatabaseType.
async ValueTask SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration newGeneralConfiguration, FileLoggingConfiguration? fileLoggingConfiguration, ElasticsearchConfiguration? elasticsearchConfiguration, ControlPanelConfiguration controlPanelConfiguration, SwarmConfiguration? swarmConfiguration, TelemetryConfiguration? telemetryConfiguration, CancellationToken cancellationToken)
Saves a given Configuration set to userConfigFileName .
SetupWizard(IIOManager ioManager, IConsole console, IHostEnvironment hostingEnvironment, IAssemblyInformationProvider assemblyInformationProvider, IDatabaseConnectionFactory dbConnectionFactory, IPlatformIdentifier platformIdentifier, IAsyncDelayer asyncDelayer, IHostApplicationLifetime applicationLifetime, IPostSetupServices postSetupServices, IOptions< GeneralConfiguration > generalConfigurationOptions, IOptions< InternalConfiguration > internalConfigurationOptions)
Initializes a new instance of the SetupWizard class.
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the SetupWizard.
async ValueTask< string?> ValidateNonExistantSqliteDBName(string databaseName, CancellationToken cancellationToken)
Check that a given SQLite databaseName is can be accessed. Also prompts the user if they want to use...
async ValueTask TestDatabaseConnection(DbConnection testConnection, DatabaseConfiguration databaseConfiguration, string databaseName, bool dbExists, CancellationToken cancellationToken)
Ensure a given testConnection works.
JsonConverter and IYamlTypeConverter for serializing global::System.Versions in semver format.
Abstraction for global::System.Console.
Definition IConsole.cs:10
Task WriteAsync(string? text, bool newLine, CancellationToken cancellationToken)
Write some text to the IConsole.
Task< string > ReadLineAsync(bool usePasswordChar, CancellationToken cancellationToken)
Read a line from the IConsole.
Task PressAnyKeyAsync(CancellationToken cancellationToken)
Wait for a key press on the IConsole.
Interface for using filesystems.
Definition IIOManager.cs:13
string ResolvePath()
Retrieve the full path of the current working directory.
string ConcatPath(params string[] paths)
Combines an array of strings into a path.
string GetDirectoryName(string path)
Gets the directory portion of a given path .
Task CreateDirectory(string path, CancellationToken cancellationToken)
Create a directory at path .
Task DeleteFile(string path, CancellationToken cancellationToken)
Deletes a file at path .
ValueTask WriteAllBytes(string path, byte[] contents, CancellationToken cancellationToken)
Writes some contents to a file at path overwriting previous content.
Task DeleteDirectory(string path, CancellationToken cancellationToken)
Recursively delete a directory, removes and does not enter any symlinks encounterd.
Task< bool > DirectoryExists(string path, CancellationToken cancellationToken)
Check that the directory at path exists.
Set of objects needed to configure an Core.Application.
For identifying the current platform.
bool IsWindows
If the current platform is a Windows platform.
DatabaseType
Type of database to user.
SetupWizardMode
Determines if the SetupWizard will run.