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