tgstation-server 5.12.7
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.Data.SqlClient;
5using System.Globalization;
6using System.IO;
7using System.Linq;
8using System.Text;
9using System.Text.RegularExpressions;
10using System.Threading;
11using System.Threading.Tasks;
12
13using Microsoft.Data.Sqlite;
14using Microsoft.Extensions.Configuration;
15using Microsoft.Extensions.Hosting;
16using Microsoft.Extensions.Logging;
17using Microsoft.Extensions.Options;
18
19using MySqlConnector;
20
21using Npgsql;
22
29using YamlDotNet.Serialization;
30
32{
35 {
40
44 readonly IConsole console;
45
49 readonly IHostEnvironment hostingEnvironment;
50
55
60
65
70
74 readonly IHostApplicationLifetime applicationLifetime;
75
80
84 TaskCompletionSource reloadTcs;
85
102 IHostEnvironment hostingEnvironment,
107 IHostApplicationLifetime applicationLifetime,
108 IConfiguration configuration,
109 IOptions<GeneralConfiguration> generalConfigurationOptions)
110 {
111 this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
112 this.console = console ?? throw new ArgumentNullException(nameof(console));
113 this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment));
114 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
115 this.dbConnectionFactory = dbConnectionFactory ?? throw new ArgumentNullException(nameof(dbConnectionFactory));
116 this.platformIdentifier = platformIdentifier ?? throw new ArgumentNullException(nameof(platformIdentifier));
117 this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
118 this.applicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime));
119 ArgumentNullException.ThrowIfNull(configuration);
120
121 generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
122
123 configuration
124 .GetReloadToken()
125 .RegisterChangeCallback(
126 state => reloadTcs?.TrySetResult(),
127 null);
128 }
129
131 public async Task StartAsync(CancellationToken cancellationToken)
132 {
133 await CheckRunWizard(cancellationToken);
134 applicationLifetime.StopApplication();
135 }
136
138 public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
139
146 async Task<bool> PromptYesNo(string question, CancellationToken cancellationToken)
147 {
148 do
149 {
150 await console.WriteAsync(question, false, cancellationToken);
151 var responseString = await console.ReadLineAsync(false, cancellationToken);
152 var upperResponse = responseString.ToUpperInvariant();
153 if (upperResponse == "Y" || upperResponse == "YES")
154 return true;
155 else if (upperResponse == "N" || upperResponse == "NO")
156 return false;
157 await console.WriteAsync("Invalid response!", true, cancellationToken);
158 }
159 while (true);
160 }
161
167 async Task<ushort?> PromptForHostingPort(CancellationToken cancellationToken)
168 {
169 await console.WriteAsync(null, true, cancellationToken);
170 await console.WriteAsync("What port would you like to connect to TGS on?", true, cancellationToken);
171 await console.WriteAsync("Note: If this is a docker container with the default port already mapped, use the default.", true, cancellationToken);
172
173 do
174 {
175 await console.WriteAsync(
176 $"API Port (leave blank for default of {GeneralConfiguration.DefaultApiPort}): ",
177 false,
178 cancellationToken);
179 var portString = await console.ReadLineAsync(false, cancellationToken);
180 if (String.IsNullOrWhiteSpace(portString))
181 return null;
182 if (UInt16.TryParse(portString, out var port) && port != 0)
183 return port;
184 await console.WriteAsync("Invalid port! Please enter a value between 1 and 65535", true, cancellationToken);
185 }
186 while (true);
187 }
188
199 DbConnection testConnection,
200 DatabaseConfiguration databaseConfiguration,
201 string databaseName,
202 bool dbExists,
203 CancellationToken cancellationToken)
204 {
205 bool isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
206 using (testConnection)
207 {
208 await console.WriteAsync("Testing connection...", true, cancellationToken);
209 await testConnection.OpenAsync(cancellationToken);
210 await console.WriteAsync("Connection successful!", true, cancellationToken);
211
212 if (databaseConfiguration.DatabaseType == DatabaseType.MariaDB
213 || databaseConfiguration.DatabaseType == DatabaseType.MySql
214 || databaseConfiguration.DatabaseType == DatabaseType.PostgresSql)
215 {
216 await console.WriteAsync($"Checking {databaseConfiguration.DatabaseType} version...", true, cancellationToken);
217 using var command = testConnection.CreateCommand();
218 command.CommandText = "SELECT VERSION()";
219 var fullVersion = (string)await command.ExecuteScalarAsync(cancellationToken);
220 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion), true, cancellationToken);
221
222 if (databaseConfiguration.DatabaseType == DatabaseType.PostgresSql)
223 {
224 var splits = fullVersion.Split(' ');
225 databaseConfiguration.ServerVersion = splits[1].TrimEnd(',');
226 }
227 else
228 {
229 var splits = fullVersion.Split('-');
230 databaseConfiguration.ServerVersion = splits.First();
231 }
232 }
233
234 if (!isSqliteDB && !dbExists)
235 {
236 await console.WriteAsync("Testing create DB permission...", true, cancellationToken);
237 using (var command = testConnection.CreateCommand())
238 {
239 // I really don't care about user sanitization here, they want to fuck their own DB? so be it
240#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
241 command.CommandText = $"CREATE DATABASE {databaseName}";
242#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
243 await command.ExecuteNonQueryAsync(cancellationToken);
244 }
245
246 await console.WriteAsync("Success!", true, cancellationToken);
247 await console.WriteAsync("Dropping test database...", true, cancellationToken);
248 using (var command = testConnection.CreateCommand())
249 {
250#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
251 command.CommandText = $"DROP DATABASE {databaseName}";
252#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
253 try
254 {
255 await command.ExecuteNonQueryAsync(cancellationToken);
256 }
257 catch (OperationCanceledException)
258 {
259 throw;
260 }
261 catch (Exception e)
262 {
263 await console.WriteAsync(e.Message, true, cancellationToken);
264 await console.WriteAsync(null, true, cancellationToken);
265 await console.WriteAsync("This should be okay, but you may want to manually drop the database before continuing!", true, cancellationToken);
266 await console.WriteAsync("Press any key to continue...", true, cancellationToken);
267 await console.PressAnyKeyAsync(cancellationToken);
268 }
269 }
270 }
271
272 await testConnection.CloseAsync();
273 }
274
275 if (isSqliteDB && !dbExists)
276 {
277 await console.WriteAsync("Deleting test database file...", true, cancellationToken);
279 SqliteConnection.ClearAllPools();
280 await ioManager.DeleteFile(databaseName, cancellationToken);
281 }
282 }
283
290 async Task<string> ValidateNonExistantSqliteDBName(string databaseName, CancellationToken cancellationToken)
291 {
292 var resolvedPath = ioManager.ResolvePath(databaseName);
293 try
294 {
295 var directoryName = ioManager.GetDirectoryName(resolvedPath);
296 bool directoryExisted = await ioManager.DirectoryExists(directoryName, cancellationToken);
297 await ioManager.CreateDirectory(directoryName, cancellationToken);
298 try
299 {
300 await ioManager.WriteAllBytes(resolvedPath, Array.Empty<byte>(), cancellationToken);
301 }
302 catch
303 {
304 if (!directoryExisted)
305 await ioManager.DeleteDirectory(directoryName, cancellationToken);
306 throw;
307 }
308 }
309 catch (IOException)
310 {
311 return null;
312 }
313
314 if (!Path.IsPathRooted(databaseName))
315 {
316 await console.WriteAsync("Note, this relative path (currently) resolves to the following:", true, cancellationToken);
317 await console.WriteAsync(resolvedPath, true, cancellationToken);
318 bool writeResolved = await PromptYesNo(
319 "Would you like to save the relative path in the configuration? If not, the full path will be saved. (y/n): ",
320 cancellationToken);
321
322 if (writeResolved)
323 databaseName = resolvedPath;
324 }
325
326 await ioManager.DeleteFile(databaseName, cancellationToken);
327 return databaseName;
328 }
329
336 async Task<DatabaseType> PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
337 {
338 if (firstTime)
339 {
340 await console.WriteAsync(String.Empty, true, cancellationToken);
341 await console.WriteAsync(
342 "NOTE: It is HIGHLY reccommended that TGS runs on a complete relational database, specfically *NOT* Sqlite.",
343 true,
344 cancellationToken);
345 await console.WriteAsync(
346 "Sqlite, by nature cannot perform several DDL operations. Because of this future compatiblility cannot be guaranteed.",
347 true,
348 cancellationToken);
349 await console.WriteAsync(
350 "This means that you may not be able to update to the next minor version of TGS without a clean re-installation!",
351 true,
352 cancellationToken);
353 await console.WriteAsync(
354 "Please consider taking the time to set up a relational database if this is meant to be a long-standing server.",
355 true,
356 cancellationToken);
357 await console.WriteAsync(String.Empty, true, cancellationToken);
358
359 await asyncDelayer.Delay(TimeSpan.FromSeconds(3), cancellationToken);
360 }
361
362 await console.WriteAsync("What SQL database type will you be using?", true, cancellationToken);
363 do
364 {
365 await console.WriteAsync(
366 String.Format(
367 CultureInfo.InvariantCulture,
368 "Please enter one of {0}, {1}, {2}, {3} or {4}: ",
369 DatabaseType.MariaDB,
370 DatabaseType.MySql,
371 DatabaseType.PostgresSql,
372 DatabaseType.SqlServer,
373 DatabaseType.Sqlite),
374 false,
375 cancellationToken);
376 var databaseTypeString = await console.ReadLineAsync(false, cancellationToken);
377 if (Enum.TryParse<DatabaseType>(databaseTypeString, out var databaseType))
378 return databaseType;
379
380 await console.WriteAsync("Invalid database type!", true, cancellationToken);
381 }
382 while (true);
383 }
384
390#pragma warning disable CA1502 // TODO: Decomplexify
391 async Task<DatabaseConfiguration> ConfigureDatabase(CancellationToken cancellationToken)
392 {
393 bool firstTime = true;
394 do
395 {
396 await console.WriteAsync(null, true, cancellationToken);
397
398 var databaseConfiguration = new DatabaseConfiguration
399 {
400 DatabaseType = await PromptDatabaseType(firstTime, cancellationToken),
401 };
402 firstTime = false;
403
404 string serverAddress = null;
405 ushort? serverPort = null;
406
407 bool isSqliteDB = databaseConfiguration.DatabaseType == DatabaseType.Sqlite;
408 if (!isSqliteDB)
409 do
410 {
411 await console.WriteAsync(null, true, cancellationToken);
412 await console.WriteAsync("Enter the server's address and port [<server>:<port> or <server>] (blank for local): ", false, cancellationToken);
413 serverAddress = await console.ReadLineAsync(false, cancellationToken);
414 if (String.IsNullOrWhiteSpace(serverAddress))
415 serverAddress = null;
416 else if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer)
417 {
418 var match = Regex.Match(serverAddress, @"^(?<server>.+):(?<port>.+)$");
419 if (match.Success)
420 {
421 serverAddress = match.Groups["server"].Value;
422 var portString = match.Groups["port"].Value;
423 if (UInt16.TryParse(portString, out var port))
424 serverPort = port;
425 else
426 {
427 await console.WriteAsync($"Failed to parse port \"{portString}\", please try again.", true, cancellationToken);
428 continue;
429 }
430 }
431 }
432
433 break;
434 }
435 while (true);
436
437 await console.WriteAsync(null, true, cancellationToken);
438 await console.WriteAsync($"Enter the database {(isSqliteDB ? "file path" : "name")} (Can be from previous installation. Otherwise, should not exist): ", false, cancellationToken);
439
440 string databaseName;
441 bool dbExists = false;
442 do
443 {
444 databaseName = await console.ReadLineAsync(false, cancellationToken);
445 if (!String.IsNullOrWhiteSpace(databaseName))
446 {
447 if (isSqliteDB)
448 {
449 dbExists = await ioManager.FileExists(databaseName, cancellationToken);
450 if (!dbExists)
451 databaseName = await ValidateNonExistantSqliteDBName(databaseName, cancellationToken);
452 }
453 else
454 dbExists = await PromptYesNo("Does this database already exist? If not, we will attempt to CREATE it. (y/n): ", cancellationToken);
455 }
456
457 if (String.IsNullOrWhiteSpace(databaseName))
458 await console.WriteAsync("Invalid database name!", true, cancellationToken);
459 else
460 break;
461 }
462 while (true);
463
464 bool useWinAuth;
465 if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && platformIdentifier.IsWindows)
466 useWinAuth = await PromptYesNo("Use Windows Authentication? (y/n): ", cancellationToken);
467 else
468 useWinAuth = false;
469
470 await console.WriteAsync(null, true, cancellationToken);
471
472 string username = null;
473 string password = null;
474 if (!isSqliteDB)
475 if (!useWinAuth)
476 {
477 await console.WriteAsync("Enter username: ", false, cancellationToken);
478 username = await console.ReadLineAsync(false, cancellationToken);
479 await console.WriteAsync("Enter password: ", false, cancellationToken);
480 password = await console.ReadLineAsync(true, cancellationToken);
481 }
482 else
483 {
484 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);
485 await console.WriteAsync("The account it uses in MSSQL is usually \"NT AUTHORITY\\SYSTEM\" and the role it needs is usually \"dbcreator\".", true, cancellationToken);
486 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);
487 }
488
489 await console.WriteAsync(null, true, cancellationToken);
490
491 DbConnection testConnection;
492 void CreateTestConnection(string connectionString) =>
493 testConnection = dbConnectionFactory.CreateConnection(
494 connectionString,
495 databaseConfiguration.DatabaseType);
496
497 switch (databaseConfiguration.DatabaseType)
498 {
499 case DatabaseType.SqlServer:
500 {
501 var csb = new SqlConnectionStringBuilder
502 {
504 DataSource = serverAddress ?? "(local)",
505 };
506
507 if (useWinAuth)
508 csb.IntegratedSecurity = true;
509 else
510 {
511 csb.UserID = username;
512 csb.Password = password;
513 }
514
515 CreateTestConnection(csb.ConnectionString);
516 csb.InitialCatalog = databaseName;
517 databaseConfiguration.ConnectionString = csb.ConnectionString;
518 }
519
520 break;
521 case DatabaseType.MariaDB:
522 case DatabaseType.MySql:
523 {
524 // MySQL/MariaDB
525 var csb = new MySqlConnectionStringBuilder
526 {
527 Server = serverAddress ?? "127.0.0.1",
528 UserID = username,
529 Password = password,
530 };
531
532 if (serverPort.HasValue)
533 csb.Port = serverPort.Value;
534
535 CreateTestConnection(csb.ConnectionString);
536 csb.Database = databaseName;
537 databaseConfiguration.ConnectionString = csb.ConnectionString;
538 }
539
540 break;
541 case DatabaseType.Sqlite:
542 {
543 var csb = new SqliteConnectionStringBuilder
544 {
545 DataSource = databaseName,
546 Mode = dbExists ? SqliteOpenMode.ReadOnly : SqliteOpenMode.ReadWriteCreate,
547 };
548
549 CreateTestConnection(csb.ConnectionString);
550 databaseConfiguration.ConnectionString = csb.ConnectionString;
551 }
552
553 break;
554 case DatabaseType.PostgresSql:
555 {
556 var csb = new NpgsqlConnectionStringBuilder
557 {
559 Host = serverAddress ?? "127.0.0.1",
560 Password = password,
561 Username = username,
562 };
563
564 if (serverPort.HasValue)
565 csb.Port = serverPort.Value;
566
567 CreateTestConnection(csb.ConnectionString);
568 csb.Database = databaseName;
569 databaseConfiguration.ConnectionString = csb.ConnectionString;
570 }
571
572 break;
573 default:
574 throw new InvalidOperationException("Invalid DatabaseType!");
575 }
576
577 try
578 {
579 await TestDatabaseConnection(testConnection, databaseConfiguration, databaseName, dbExists, cancellationToken);
580
581 return databaseConfiguration;
582 }
583 catch (OperationCanceledException)
584 {
585 throw;
586 }
587 catch (Exception e)
588 {
589 await console.WriteAsync(e.Message, true, cancellationToken);
590 await console.WriteAsync(null, true, cancellationToken);
591 await console.WriteAsync("Retrying database configuration...", true, cancellationToken);
592 }
593 }
594 while (true);
595 }
596#pragma warning restore CA1502
597
603 async Task<GeneralConfiguration> ConfigureGeneral(CancellationToken cancellationToken)
604 {
605 var newGeneralConfiguration = new GeneralConfiguration
606 {
608 };
609
610 do
611 {
612 await console.WriteAsync(null, true, cancellationToken);
613 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", newGeneralConfiguration.MinimumPasswordLength), false, cancellationToken);
614 var passwordLengthString = await console.ReadLineAsync(false, cancellationToken);
615 if (String.IsNullOrWhiteSpace(passwordLengthString))
616 break;
617 if (UInt32.TryParse(passwordLengthString, out var passwordLength) && passwordLength >= 0)
618 {
619 newGeneralConfiguration.MinimumPasswordLength = passwordLength;
620 break;
621 }
622
623 await console.WriteAsync("Please enter a positive integer!", true, cancellationToken);
624 }
625 while (true);
626
627 do
628 {
629 await console.WriteAsync(null, true, cancellationToken);
630 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);
631 var topicTimeoutString = await console.ReadLineAsync(false, cancellationToken);
632 if (String.IsNullOrWhiteSpace(topicTimeoutString))
633 break;
634 if (UInt32.TryParse(topicTimeoutString, out var topicTimeout) && topicTimeout >= 0)
635 {
636 newGeneralConfiguration.ByondTopicTimeout = topicTimeout;
637 break;
638 }
639
640 await console.WriteAsync("Please enter a positive integer!", true, cancellationToken);
641 }
642 while (true);
643
644 await console.WriteAsync(null, true, cancellationToken);
645 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);
646 await console.WriteAsync("GitHub personal access token: ", false, cancellationToken);
647 newGeneralConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken);
648 if (String.IsNullOrWhiteSpace(newGeneralConfiguration.GitHubAccessToken))
649 newGeneralConfiguration.GitHubAccessToken = null;
650
651 newGeneralConfiguration.HostApiDocumentation = await PromptYesNo("Host API Documentation? (y/n): ", cancellationToken);
652
653 return newGeneralConfiguration;
654 }
655
661 async Task<FileLoggingConfiguration> ConfigureLogging(CancellationToken cancellationToken)
662 {
663 var fileLoggingConfiguration = new FileLoggingConfiguration();
664 await console.WriteAsync(null, true, cancellationToken);
665 fileLoggingConfiguration.Disable = !await PromptYesNo("Enable file logging? (y/n): ", cancellationToken);
666
667 if (!fileLoggingConfiguration.Disable)
668 {
669 do
670 {
671 await console.WriteAsync("Log file directory path (leave blank for default): ", false, cancellationToken);
672 fileLoggingConfiguration.Directory = await console.ReadLineAsync(false, cancellationToken);
673 if (String.IsNullOrWhiteSpace(fileLoggingConfiguration.Directory))
674 {
675 fileLoggingConfiguration.Directory = null;
676 break;
677 }
678
679 // test a write of it
680 await console.WriteAsync(null, true, cancellationToken);
681 await console.WriteAsync("Testing directory access...", true, cancellationToken);
682 try
683 {
684 await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken);
685 var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid()));
686 await ioManager.WriteAllBytes(testFile, Array.Empty<byte>(), cancellationToken);
687 try
688 {
689 await ioManager.DeleteFile(testFile, cancellationToken);
690 }
691 catch (OperationCanceledException)
692 {
693 throw;
694 }
695 catch (Exception e)
696 {
697 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken);
698 await console.WriteAsync(e.Message, true, cancellationToken);
699 await console.WriteAsync(null, true, cancellationToken);
700 }
701
702 break;
703 }
704 catch (OperationCanceledException)
705 {
706 throw;
707 }
708 catch (Exception e)
709 {
710 await console.WriteAsync(e.Message, true, cancellationToken);
711 await console.WriteAsync(null, true, cancellationToken);
712 await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken);
713 }
714 }
715 while (true);
716
717 async Task<LogLevel?> PromptLogLevel(string question)
718 {
719 do
720 {
721 await console.WriteAsync(null, true, cancellationToken);
722 await console.WriteAsync(question, true, cancellationToken);
723 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);
724 var responseString = await console.ReadLineAsync(false, cancellationToken);
725 if (String.IsNullOrWhiteSpace(responseString))
726 return null;
727 if (Enum.TryParse<LogLevel>(responseString, out var logLevel) && logLevel != LogLevel.None)
728 return logLevel;
729 await console.WriteAsync("Invalid log level!", true, cancellationToken);
730 }
731 while (true);
732 }
733
734 fileLoggingConfiguration.LogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for normal logs (default {0}).", fileLoggingConfiguration.LogLevel)) ?? fileLoggingConfiguration.LogLevel;
735 fileLoggingConfiguration.MicrosoftLogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for Microsoft logs (VERY verbose, default {0}).", fileLoggingConfiguration.MicrosoftLogLevel)) ?? fileLoggingConfiguration.MicrosoftLogLevel;
736 }
737
738 return fileLoggingConfiguration;
739 }
740
746 async Task<ElasticsearchConfiguration> ConfigureElasticsearch(CancellationToken cancellationToken)
747 {
748 var elasticsearchConfiguration = new ElasticsearchConfiguration();
749 await console.WriteAsync(null, true, cancellationToken);
750 elasticsearchConfiguration.Enable = await PromptYesNo("Enable logging to an external ElasticSearch server? (y/n): ", cancellationToken);
751
752 if (elasticsearchConfiguration.Enable)
753 {
754 do
755 {
756 await console.WriteAsync("ElasticSearch server endpoint (Include protocol and port, leave blank for http://127.0.0.1:9200): ", false, cancellationToken);
757 elasticsearchConfiguration.Host = await console.ReadLineAsync(false, cancellationToken);
758 if (!String.IsNullOrWhiteSpace(elasticsearchConfiguration.Host))
759 {
760 break;
761 }
762 }
763 while (true);
764
765 do
766 {
767 await console.WriteAsync("Enter Elasticsearch username: ", false, cancellationToken);
768 elasticsearchConfiguration.Username = await console.ReadLineAsync(false, cancellationToken);
769 if (!String.IsNullOrWhiteSpace(elasticsearchConfiguration.Username))
770 {
771 break;
772 }
773 }
774 while (true);
775
776 do
777 {
778 await console.WriteAsync("Enter password: ", false, cancellationToken);
779 elasticsearchConfiguration.Password = await console.ReadLineAsync(true, cancellationToken);
780 if (!String.IsNullOrWhiteSpace(elasticsearchConfiguration.Username))
781 {
782 break;
783 }
784 }
785 while (true);
786 }
787
788 return elasticsearchConfiguration;
789 }
790
796 async Task<ControlPanelConfiguration> ConfigureControlPanel(CancellationToken cancellationToken)
797 {
798 var config = new ControlPanelConfiguration
799 {
800 Enable = await PromptYesNo("Enable the web control panel? (y/n): ", cancellationToken),
801 AllowAnyOrigin = await PromptYesNo("Allow web control panels hosted elsewhere to access the server? (Access-Control-Allow-Origin: *) (y/n): ", cancellationToken),
802 };
803
804 if (!config.AllowAnyOrigin)
805 {
806 await console.WriteAsync("Enter a comma seperated list of CORS allowed origins (optional): ", false, cancellationToken);
807 var commaSeperatedOrigins = await console.ReadLineAsync(false, cancellationToken);
808 if (!String.IsNullOrWhiteSpace(commaSeperatedOrigins))
809 {
810 var splits = commaSeperatedOrigins.Split(',');
811 config.AllowedOrigins = new List<string>(splits.Select(x => x.Trim()));
812 }
813 }
814
815 return config;
816 }
817
823 async Task<SwarmConfiguration> ConfigureSwarm(CancellationToken cancellationToken)
824 {
825 var enable = await PromptYesNo("Enable swarm mode? (y/n): ", cancellationToken);
826 if (!enable)
827 return null;
828
829 string identifer;
830 do
831 {
832 await console.WriteAsync("Enter this server's identifer: ", false, cancellationToken);
833 identifer = await console.ReadLineAsync(false, cancellationToken);
834 }
835 while (String.IsNullOrWhiteSpace(identifer));
836
837 async Task<Uri> ParseAddress(string question)
838 {
839 Uri address;
840 do
841 {
842 await console.WriteAsync(question, false, cancellationToken);
843 var addressString = await console.ReadLineAsync(false, cancellationToken);
844 if (Uri.TryCreate(addressString, UriKind.Absolute, out address)
845 && address.Scheme != Uri.UriSchemeHttp
846 && address.Scheme != Uri.UriSchemeHttps)
847 address = null;
848 }
849 while (address == null);
850
851 return address;
852 }
853
854 var address = await ParseAddress("Enter this server's HTTP(S) address: ");
855 string privateKey;
856 do
857 {
858 await console.WriteAsync("Enter the swarm private key: ", false, cancellationToken);
859 privateKey = await console.ReadLineAsync(false, cancellationToken);
860 }
861 while (String.IsNullOrWhiteSpace(privateKey));
862
863 var controller = await PromptYesNo("Is this server the swarm's controller? (y/n): ", cancellationToken);
864 Uri controllerAddress = null;
865 if (!controller)
866 controllerAddress = await ParseAddress("Enter the swarm controller's HTTP(S) address: ");
867
868 return new SwarmConfiguration
869 {
870 Address = address,
871 ControllerAddress = controllerAddress,
872 Identifier = identifer,
873 PrivateKey = privateKey,
874 };
875 }
876
891 string userConfigFileName,
892 ushort? hostingPort,
893 DatabaseConfiguration databaseConfiguration,
894 GeneralConfiguration newGeneralConfiguration,
895 FileLoggingConfiguration fileLoggingConfiguration,
896 ElasticsearchConfiguration elasticsearchConfiguration,
897 ControlPanelConfiguration controlPanelConfiguration,
898 SwarmConfiguration swarmConfiguration,
899 CancellationToken cancellationToken)
900 {
901 await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken);
902
903 newGeneralConfiguration.ApiPort = hostingPort ?? GeneralConfiguration.DefaultApiPort;
904 newGeneralConfiguration.ConfigVersion = GeneralConfiguration.CurrentConfigVersion;
905 var map = new Dictionary<string, object>()
906 {
907 { DatabaseConfiguration.Section, databaseConfiguration },
908 { GeneralConfiguration.Section, newGeneralConfiguration },
909 { FileLoggingConfiguration.Section, fileLoggingConfiguration },
910 { ElasticsearchConfiguration.Section, elasticsearchConfiguration },
911 { ControlPanelConfiguration.Section, controlPanelConfiguration },
912 { SwarmConfiguration.Section, swarmConfiguration },
913 };
914
915 var builder = new SerializerBuilder()
916 .WithTypeConverter(new VersionConverter());
917
918 if (userConfigFileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
919 builder.JsonCompatible();
920
921 var serializer = new SerializerBuilder()
922 .WithTypeConverter(new VersionConverter())
923 .Build();
924
925 var serializedYaml = serializer.Serialize(map);
926
927 // big hack, but, prevent the default control panel channel from being overridden
928 serializedYaml = serializedYaml.Replace(
929 $"\n {nameof(ControlPanelConfiguration.Channel)}: ",
930 String.Empty,
931 StringComparison.Ordinal)
932 .Replace("\r", String.Empty, StringComparison.Ordinal);
933
934 var configBytes = Encoding.UTF8.GetBytes(serializedYaml);
935
936 reloadTcs = new TaskCompletionSource();
937
938 try
939 {
940 await ioManager.WriteAllBytes(userConfigFileName, configBytes, cancellationToken);
941
942 // Ensure the reload
944 using (cancellationToken.Register(() => reloadTcs.TrySetCanceled()))
945 await reloadTcs.Task;
946 }
947 catch (OperationCanceledException)
948 {
949 throw;
950 }
951 catch (Exception e)
952 {
953 await console.WriteAsync(e.Message, true, cancellationToken);
954 await console.WriteAsync(null, true, cancellationToken);
955 await console.WriteAsync("For your convienence, here's the yaml we tried to write out:", true, cancellationToken);
956 await console.WriteAsync(null, true, cancellationToken);
957 await console.WriteAsync(serializedYaml, true, cancellationToken);
958 await console.WriteAsync(null, true, cancellationToken);
959 await console.WriteAsync("Press any key to exit...", true, cancellationToken);
960 await console.PressAnyKeyAsync(cancellationToken);
961 throw new OperationCanceledException();
962 }
963 }
964
971 async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
972 {
973 // welcome message
974 await console.WriteAsync("Welcome to tgstation-server!", true, cancellationToken);
975 await console.WriteAsync("This wizard will help you configure your server.", true, cancellationToken);
976
977 var hostingPort = await PromptForHostingPort(cancellationToken);
978
979 var databaseConfiguration = await ConfigureDatabase(cancellationToken);
980
981 var newGeneralConfiguration = await ConfigureGeneral(cancellationToken);
982
983 var fileLoggingConfiguration = await ConfigureLogging(cancellationToken);
984
985 var elasticSearchConfiguration = await ConfigureElasticsearch(cancellationToken);
986
987 var controlPanelConfiguration = await ConfigureControlPanel(cancellationToken);
988
989 var swarmConfiguration = await ConfigureSwarm(cancellationToken);
990
991 await console.WriteAsync(null, true, cancellationToken);
992
993 await SaveConfiguration(
994 userConfigFileName,
995 hostingPort,
996 databaseConfiguration,
997 newGeneralConfiguration,
998 fileLoggingConfiguration,
999 elasticSearchConfiguration,
1000 controlPanelConfiguration,
1001 swarmConfiguration,
1002 cancellationToken);
1003 }
1004
1010 async Task CheckRunWizard(CancellationToken cancellationToken)
1011 {
1012 var setupWizardMode = generalConfiguration.SetupWizardMode;
1013 if (setupWizardMode == SetupWizardMode.Never)
1014 return;
1015
1016 var forceRun = setupWizardMode == SetupWizardMode.Force || setupWizardMode == SetupWizardMode.Only;
1017 if (!console.Available)
1018 {
1019 if (forceRun)
1020 throw new InvalidOperationException("Asked to run setup wizard with no console avaliable!");
1021 return;
1022 }
1023
1024 var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.yml", hostingEnvironment.EnvironmentName);
1025
1026 async Task HandleSetupCancel()
1027 {
1028 // DCTx2: Operation should always run
1029 await console.WriteAsync(String.Empty, true, default);
1030 await console.WriteAsync("Aborting setup!", true, default);
1031 }
1032
1033 // Link passed cancellationToken with cancel key press
1034 Task finalTask = Task.CompletedTask;
1035 using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, console.CancelKeyPress))
1036 using ((cancellationToken = cts.Token).Register(() => finalTask = HandleSetupCancel()))
1037 try
1038 {
1039 var exists = await ioManager.FileExists(userConfigFileName, cancellationToken);
1040 if (!exists)
1041 {
1042 var legacyJsonFileName = $"appsettings.{hostingEnvironment.EnvironmentName}.json";
1043 exists = await ioManager.FileExists(legacyJsonFileName, cancellationToken);
1044 if (exists)
1045 userConfigFileName = legacyJsonFileName;
1046 }
1047
1048 bool shouldRunBasedOnAutodetect;
1049 if (exists)
1050 {
1051 var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken);
1052 var contents = Encoding.UTF8.GetString(bytes);
1053 var lines = contents.Split('\n', StringSplitOptions.RemoveEmptyEntries);
1054 var existingConfigIsEmpty = lines
1055 .Select(line => line.Trim())
1056 .All(line => line[0] == '#' || line == "{}" || line.Length == 0);
1057 shouldRunBasedOnAutodetect = existingConfigIsEmpty;
1058 }
1059 else
1060 shouldRunBasedOnAutodetect = true;
1061
1062 if (!shouldRunBasedOnAutodetect)
1063 {
1064 if (forceRun)
1065 {
1066 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);
1067
1068 forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken);
1069 }
1070
1071 if (!forceRun)
1072 return;
1073 }
1074
1075 // flush the logs to prevent console conflicts
1076 await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken);
1077
1078 await RunWizard(userConfigFileName, cancellationToken);
1079 }
1080 finally
1081 {
1082 await finalTask;
1083 }
1084 }
1085 }
1086}
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...
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.
Configuration for the server swarm system.
const string Section
The key for the Microsoft.Extensions.Configuration.IConfigurationSection the SwarmConfiguration resid...
JsonConverter and IYamlTypeConverter for serializing global::System.Versions in semver format.
readonly IIOManager ioManager
The IIOManager for the SetupWizard.
Definition: SetupWizard.cs:39
Task StopAsync(CancellationToken cancellationToken)
readonly IHostEnvironment hostingEnvironment
The IHostEnvironment for the SetupWizard.
Definition: SetupWizard.cs:49
async Task CheckRunWizard(CancellationToken cancellationToken)
Check if it should and run the SetupWizard if necessary.
readonly GeneralConfiguration generalConfiguration
The GeneralConfiguration for the SetupWizard.
Definition: SetupWizard.cs:79
async Task< DatabaseType > PromptDatabaseType(bool firstTime, CancellationToken cancellationToken)
Prompt the user for the DatabaseType.
Definition: SetupWizard.cs:336
async Task< GeneralConfiguration > ConfigureGeneral(CancellationToken cancellationToken)
Prompts the user to create a GeneralConfiguration.
Definition: SetupWizard.cs:603
readonly IAssemblyInformationProvider assemblyInformationProvider
The IAssemblyInformationProvider for the SetupWizard.
Definition: SetupWizard.cs:54
async Task< 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:290
async Task< ElasticsearchConfiguration > ConfigureElasticsearch(CancellationToken cancellationToken)
Prompts the user to create a ElasticsearchConfiguration.
Definition: SetupWizard.cs:746
async Task< FileLoggingConfiguration > ConfigureLogging(CancellationToken cancellationToken)
Prompts the user to create a FileLoggingConfiguration.
Definition: SetupWizard.cs:661
async Task< ushort?> PromptForHostingPort(CancellationToken cancellationToken)
Prompts the user to enter the port to host TGS on.
Definition: SetupWizard.cs:167
async Task< bool > PromptYesNo(string question, CancellationToken cancellationToken)
A prompt for a yes or no value.
Definition: SetupWizard.cs:146
readonly IPlatformIdentifier platformIdentifier
The IPlatformIdentifier for the SetupWizard.
Definition: SetupWizard.cs:64
readonly IDatabaseConnectionFactory dbConnectionFactory
The IDatabaseConnectionFactory for the SetupWizard.
Definition: SetupWizard.cs:59
readonly IConsole console
The IConsole for the SetupWizard.
Definition: SetupWizard.cs:44
TaskCompletionSource reloadTcs
A TaskCompletionSource that will complete when the IConfiguration is reloaded.
Definition: SetupWizard.cs:84
async Task TestDatabaseConnection(DbConnection testConnection, DatabaseConfiguration databaseConfiguration, string databaseName, bool dbExists, CancellationToken cancellationToken)
Ensure a given testConnection works.
Definition: SetupWizard.cs:198
async Task 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:890
async Task< DatabaseConfiguration > ConfigureDatabase(CancellationToken cancellationToken)
Prompts the user to create a DatabaseConfiguration.
Definition: SetupWizard.cs:391
async Task< SwarmConfiguration > ConfigureSwarm(CancellationToken cancellationToken)
Prompts the user to create a SwarmConfiguration.
Definition: SetupWizard.cs:823
readonly IHostApplicationLifetime applicationLifetime
The IHostApplicationLifetime for the SetupWizard.
Definition: SetupWizard.cs:74
SetupWizard(IIOManager ioManager, IConsole console, IHostEnvironment hostingEnvironment, IAssemblyInformationProvider assemblyInformationProvider, IDatabaseConnectionFactory dbConnectionFactory, IPlatformIdentifier platformIdentifier, IAsyncDelayer asyncDelayer, IHostApplicationLifetime applicationLifetime, IConfiguration configuration, IOptions< GeneralConfiguration > generalConfigurationOptions)
Initializes a new instance of the SetupWizard class.
Definition: SetupWizard.cs:99
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the SetupWizard.
Definition: SetupWizard.cs:69
async Task StartAsync(CancellationToken cancellationToken)
Definition: SetupWizard.cs:131
async Task< ControlPanelConfiguration > ConfigureControlPanel(CancellationToken cancellationToken)
Prompts the user to create a ControlPanelConfiguration.
Definition: SetupWizard.cs:796
async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
Runs the SetupWizard.
Definition: SetupWizard.cs:971
DbConnection CreateConnection(string connectionString, DatabaseType databaseType)
Create a DbConnection.
Abstraction for global::System.Console.
Definition: IConsole.cs:10
CancellationToken CancelKeyPress
Gets a CancellationToken that triggers if Crtl+C or an equivalent is pressed.
Definition: IConsole.cs:19
bool Available
If the IConsole is visible to the user.
Definition: IConsole.cs:14
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.
Task WriteAsync(string text, bool newLine, CancellationToken cancellationToken)
Write some text to 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 .
Task< byte[]> ReadAllBytes(string path, CancellationToken cancellationToken)
Returns all the contents of a file at path as a byte array.
Task DeleteDirectory(string path, CancellationToken cancellationToken)
Recursively delete a directory, removes and does not enter any symlinks encounterd.
Task< bool > FileExists(string path, CancellationToken cancellationToken)
Check that the file at path exists.
Task< bool > DirectoryExists(string path, CancellationToken cancellationToken)
Check that the directory at path exists.
Task WriteAllBytes(string path, byte[] contents, CancellationToken cancellationToken)
Writes some contents to a file at path overwriting previous content.
For identifying the current platform.
bool IsWindows
If the current platform is a Windows platform.
Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken)
Create a Task that completes after a given timeSpan .
DatabaseType
Type of database to user.
Definition: DatabaseType.cs:7
SetupWizardMode
Determines if the SetupWizard will run.