From 5d179653e0463879ed85c8b84ef200f0312fad8a Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 12:28:26 -0400 Subject: [PATCH] Big one Added Byond.TopicSender timeout configuration to general configuration Added IConsole Changed up ITokenFactory to be the source of TokenValidationParameters Remove unused usings Refactored SetupWizard into it's own service Greatly organized and commented Application Added SetupWizardMode GeneralConfiguration, removed ConfigCheckOnly Added DefaultMinimumPasswordLength to GeneralConfiguration --- .../Configuration/GeneralConfiguration.cs | 27 +- .../Configuration/SetupWizardMode.cs | 25 + src/Tgstation.Server.Host/Core/Application.cs | 468 +++-------------- .../Core/ISetupWizard.cs | 18 + src/Tgstation.Server.Host/Core/SetupWizard.cs | 471 ++++++++++++++++++ src/Tgstation.Server.Host/IO/Console.cs | 59 +++ src/Tgstation.Server.Host/IO/IConsole.cs | 40 ++ .../Security/ITokenFactory.cs | 7 +- .../Security/TokenFactory.cs | 38 +- src/Tgstation.Server.Host/appsettings.json | 3 +- 10 files changed, 747 insertions(+), 409 deletions(-) create mode 100644 src/Tgstation.Server.Host/Configuration/SetupWizardMode.cs create mode 100644 src/Tgstation.Server.Host/Core/ISetupWizard.cs create mode 100644 src/Tgstation.Server.Host/Core/SetupWizard.cs create mode 100644 src/Tgstation.Server.Host/IO/Console.cs create mode 100644 src/Tgstation.Server.Host/IO/IConsole.cs diff --git a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index 53509172bb..4dc3ef58be 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -1,4 +1,7 @@ -namespace Tgstation.Server.Host.Configuration +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Tgstation.Server.Host.Configuration { /// /// General configuration options @@ -10,10 +13,20 @@ /// public const string Section = "General"; + /// + /// The default value for + /// + const uint DefaultMinimumPasswordLength = 15; + + /// + /// The default value for + /// + const int DefaultByondTopicTimeout = 5000; + /// /// Minimum length of database user passwords /// - public uint MinimumPasswordLength { get; set; } + public uint MinimumPasswordLength { get; set; } = DefaultMinimumPasswordLength; /// /// A GitHub personal access token to use for bypassing rate limits on requests. Requires no scopes @@ -21,8 +34,14 @@ public string GitHubAccessToken { get; set; } /// - /// If the should just check if the configuration wizard needs to be run and then exit + /// The /// - public bool ConfigCheckOnly { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public SetupWizardMode SetupWizardMode { get; set; } + + /// + /// The timeout in milliseconds for sending and receiving topics to/from DreamDaemon. Note that a single topic exchange can take up to twice this value + /// + public int ByondTopicTimeout { get; set; } = DefaultByondTopicTimeout; } } diff --git a/src/Tgstation.Server.Host/Configuration/SetupWizardMode.cs b/src/Tgstation.Server.Host/Configuration/SetupWizardMode.cs new file mode 100644 index 0000000000..4f2b59204a --- /dev/null +++ b/src/Tgstation.Server.Host/Configuration/SetupWizardMode.cs @@ -0,0 +1,25 @@ +namespace Tgstation.Server.Host.Configuration +{ + /// + /// Determines if the will run + /// + public enum SetupWizardMode + { + /// + /// Run the wizard if the appsettings.{Environment}.json is not present or empty + /// + Autodetect, + /// + /// Force run the wizard + /// + Force, + /// + /// Only run the wizard and exit + /// + Only, + /// + /// Never run the wizard + /// + Never + } +} diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 31237dbbb7..c7b55d8bdc 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -10,22 +10,16 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; -using Microsoft.IdentityModel.Tokens; -using MySql.Data.MySqlClient; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Serilog; using Serilog.Events; using Serilog.Formatting.Display; using System; -using System.Collections.Generic; -using System.Data; -using System.Data.SqlClient; using System.Globalization; using System.IdentityModel.Tokens.Jwt; using System.Reflection; using System.Runtime.InteropServices; -using System.Text; using System.Threading.Tasks; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Byond; @@ -82,383 +76,81 @@ namespace Tgstation.Server.Host.Core VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version); } - bool CheckRunSetupWizard(IIOManager ioManager) - { - if (!Environment.UserInteractive) - return false; - var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName); - var existenceTask = ioManager.FileExists(userConfigFileName, default); - var exists = existenceTask.GetAwaiter().GetResult(); - - if (exists) - { - var readTask = ioManager.ReadAllBytes(userConfigFileName, default); - var bytes = readTask.GetAwaiter().GetResult(); - var contents = Encoding.UTF8.GetString(bytes); - if (!String.IsNullOrWhiteSpace(contents)) - return false; - } - - //non-present or empty config json - //make our own with blackjack and hookers - - Console.WriteLine("Welcome to tgstation-server 4!"); - Console.WriteLine("This wizard will help you configure your server."); - Console.WriteLine(); - Console.WriteLine("What port would you like to connect to TGS on?"); - Console.WriteLine("Note: If this is a docker container with the default port already mapped, use the default."); - - ushort? port = null; - do - { - Console.Write("API Port (leave blank for default): "); - var portString = Console.ReadLine(); - if (String.IsNullOrWhiteSpace(portString)) - break; - if (UInt16.TryParse(portString, out var concretePort) && concretePort != 0) - { - port = concretePort; - break; - } - Console.WriteLine("Invalid port! Please enter a value between 1 and 65535"); - } - while (true); - - - string GetPassword() - { - var pwd = new StringBuilder(); - do - { - var i = Console.ReadKey(true); - if (i.Key == ConsoleKey.Enter) - break; - else if (i.Key == ConsoleKey.Backspace) - { - if (pwd.Length > 0) - { - --pwd.Length; - Console.Write("\b \b"); - } - } - else if (i.KeyChar != '\u0000') // KeyChar == '\u0000' if the key pressed does not correspond to a printable character, e.g. F1, Pause-Break, etc - { - pwd.Append(i.KeyChar); - Console.Write("*"); - } - } - while (true); - Console.WriteLine(); - return pwd.ToString(); - } - - DatabaseConfiguration databaseConfiguration; - do - { - Console.WriteLine(); - Console.WriteLine("What SQL database type will you be using?"); - - databaseConfiguration = new DatabaseConfiguration(); - do - { - Console.Write(String.Format(CultureInfo.InvariantCulture, "Please enter one of {0}, {1}, or {2}: ", DatabaseType.MariaDB, DatabaseType.SqlServer, DatabaseType.MySql)); - var databaseTypeString = Console.ReadLine(); - if (Enum.TryParse(databaseTypeString, out var databaseType)) - { - databaseConfiguration.DatabaseType = databaseType; - break; - } - Console.WriteLine("Invalid database type!"); - } - while (true); - - Console.WriteLine(); - Console.Write("Enter the server's address and port (blank for local): "); - var serverAddress = Console.ReadLine(); - if (String.IsNullOrWhiteSpace(serverAddress)) - serverAddress = null; - - Console.WriteLine(); - Console.Write("Enter the database name (Can be from previous installation. Otherwise, should not exist): "); - var databaseName = Console.ReadLine(); - - bool dbExists; - do - { - Console.Write("Does this database already exist? (y/n): "); - var responseString = Console.ReadLine(); - var upperResponse = responseString.ToUpperInvariant(); - if (upperResponse == "Y" || upperResponse == "YES") - { - dbExists = true; - break; - } - else if (upperResponse == "N" || upperResponse == "NO") - { - dbExists = false; - break; - } - Console.WriteLine("Invalid response!"); - } - while (true); - - bool? useWinAuth; - if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - do - { - Console.Write("Use Windows Authentication? (y/n): "); - var responseString = Console.ReadLine(); - var upperResponse = responseString.ToUpperInvariant(); - if (upperResponse == "Y" || upperResponse == "YES") - { - useWinAuth = true; - break; - } - else if (upperResponse == "N" || upperResponse == "NO") - { - useWinAuth = false; - break; - } - Console.WriteLine("Invalid response!"); - } - while (true); - else - useWinAuth = null; - - Console.WriteLine(); - - string username = null; - string password = null; - if (useWinAuth != true) - { - Console.Write("Enter username: "); - username = Console.ReadLine(); - Console.Write("Enter password: "); - password = GetPassword(); - Console.WriteLine(); - } - - IDbConnection testConnection; - if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer) - { - var csb = new SqlConnectionStringBuilder - { - ApplicationName = VersionPrefix, - DataSource = serverAddress ?? "(local)" - }; - if (useWinAuth.Value) - csb.IntegratedSecurity = true; - else - { - csb.UserID = username; - csb.Password = password; - } - testConnection = new SqlConnection - { - ConnectionString = csb.ConnectionString - }; - csb.InitialCatalog = databaseName; - databaseConfiguration.ConnectionString = csb.ConnectionString; - } - else - { - var csb = new MySqlConnectionStringBuilder - { - Server = serverAddress ?? "127.0.0.1", - UserID = username, - Password = password - }; - testConnection = new MySqlConnection - { - ConnectionString = csb.ConnectionString - }; - csb.Database = databaseName; - databaseConfiguration.ConnectionString = csb.ConnectionString; - } - - try - { - using (testConnection) - { - Console.WriteLine("Testing connection..."); - testConnection.Open(); - Console.WriteLine("Connection successful!"); - - if (databaseConfiguration.DatabaseType != DatabaseType.SqlServer) - { - Console.WriteLine("Checking MySQL/MariaDB version..."); - using (var command = testConnection.CreateCommand()) - { - command.CommandText = "SELECT VERSION()"; - var fullVersion = (string)command.ExecuteScalar(); - Console.WriteLine(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion)); - var splits = fullVersion.Split('-'); - databaseConfiguration.MySqlServerVersion = splits[0]; - } - } - - if (!dbExists) - { - Console.WriteLine("Testing create DB permission..."); - using (var command = testConnection.CreateCommand()) - { - command.CommandText = String.Format(CultureInfo.InvariantCulture, "CREATE DATABASE {0}", databaseName); - command.ExecuteNonQuery(); - } - Console.WriteLine("Success!"); - Console.WriteLine("Dropping test database..."); - using (var command = testConnection.CreateCommand()) - { - command.CommandText = String.Format(CultureInfo.InvariantCulture, "DROP DATABASE {0}", databaseName); - try - { - command.ExecuteNonQuery(); - } - catch (Exception e) - { - Console.WriteLine(e.Message); - Console.WriteLine(); - Console.WriteLine("This should be okay, but you may want to manually drop the database before continuing!"); - Console.WriteLine("Press any key to continue..."); - Console.ReadKey(); - } - } - } - } - - break; - } - catch (Exception e) - { - Console.WriteLine(e.Message); - Console.WriteLine(); - Console.WriteLine("Retrying database configuration..."); - } - } while (true); - - var generalConfiguration = new GeneralConfiguration - { - MinimumPasswordLength = 15 - }; - do - { - Console.WriteLine(); - Console.Write("Minimum database user password length (leave blank for default of 15): "); - var passwordLengthString = Console.ReadLine(); - if (String.IsNullOrWhiteSpace(passwordLengthString)) - break; - if (UInt32.TryParse(passwordLengthString, out var passwordLength)) - { - generalConfiguration.MinimumPasswordLength = passwordLength; - break; - } - Console.WriteLine("Please enter a positive integer!"); - } - while (true); - - Console.WriteLine(); - Console.WriteLine("Enter a GitHub personal access token to bypass some rate limits (this is optional and does not require any scopes)"); - Console.Write("GitHub personal access token: "); - generalConfiguration.GitHubAccessToken = GetPassword(); - if (String.IsNullOrWhiteSpace(generalConfiguration.GitHubAccessToken)) - generalConfiguration.GitHubAccessToken = null; - - Console.WriteLine(); - Console.WriteLine(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName)); - - var map = new Dictionary() - { - { DatabaseConfiguration.Section, databaseConfiguration }, - { GeneralConfiguration.Section, generalConfiguration } - }; - - if (port.HasValue) - map.Add("Kestrel", new { - EndPoints = new - { - Http = new - { - Url = String.Format(CultureInfo.InvariantCulture, "http://0.0.0.0:{0}", port) - } - } - }); - - var json = JsonConvert.SerializeObject(map, Formatting.Indented); - var configBytes = Encoding.UTF8.GetBytes(json); - - var writeTask = ioManager.WriteAllBytes(userConfigFileName, configBytes, default); - try - { - writeTask.GetAwaiter().GetResult(); - } - catch (Exception e) - { - Console.WriteLine(e.Message); - Console.WriteLine(); - Console.WriteLine("For your convienence, here's the text we tried to write out:"); - Console.WriteLine(); - Console.WriteLine(json); - Console.WriteLine(); - Console.WriteLine("Press any key to exit..."); - Console.ReadKey(); - throw new OperationCanceledException(); - } - - Console.WriteLine("Waiting for configuration changes to reload..."); - Task.Delay(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); - - return true; - } - /// /// Configure dependency injected services /// /// The to configure + /// The representing the lifetime of the public void ConfigureServices(IServiceCollection services) { if (services == null) throw new ArgumentNullException(nameof(services)); + //needful + services.AddSingleton(this); + + //configure configuration services.Configure(configuration.GetSection(UpdatesConfiguration.Section)); services.Configure(configuration.GetSection(DatabaseConfiguration.Section)); services.Configure(configuration.GetSection(GeneralConfiguration.Section)); services.Configure(configuration.GetSection(FileLoggingConfiguration.Section)); + //enable options which give us config reloading services.AddOptions(); + //need this instance for later configuration var ioManager = new DefaultIOManager(); - var ranWizard = CheckRunSetupWizard(ioManager); + + //setup stuff for setup wizard + services.AddSingleton(ioManager); + services.AddSingleton(); + services.AddSingleton(); + + //needed here for JWT configuration + services.AddSingleton(); GeneralConfiguration generalConfiguration; DatabaseConfiguration databaseConfiguration; FileLoggingConfiguration fileLoggingConfiguration; + ITokenFactory tokenFactory; + + //temporarily build the service provider in it's current state + //do it here so we can run the setup wizard if necessary + //also allows us to get some options and other services we need for continued configuration using (var provider = services.BuildServiceProvider()) { + //run the wizard if necessary + var setupWizard = provider.GetRequiredService(); + var applicationLifetime = provider.GetRequiredService(); + var setupWizardRan = setupWizard.CheckRunWizard(applicationLifetime.ApplicationStopping).GetAwaiter().GetResult(); + + //load the configuration options we need var generalOptions = provider.GetRequiredService>(); generalConfiguration = generalOptions.Value; - if (generalConfiguration.ConfigCheckOnly) - throw new OperationCanceledException("Configuration check complete!"); - - if (ranWizard) - Console.WriteLine("Now launching TGS..."); + //unless this is set, in which case, we leave + if (setupWizardRan && generalConfiguration.SetupWizardMode == SetupWizardMode.Only) + //we don't inject a logger in the constuctor to log this because it's not yet configured + throw new OperationCanceledException("Exiting due to SetupWizardMode configuration!"); var dbOptions = provider.GetRequiredService>(); databaseConfiguration = dbOptions.Value; var loggingOptions = provider.GetRequiredService>(); fileLoggingConfiguration = loggingOptions.Value; + + tokenFactory = provider.GetRequiredService(); } - + //setup file logging via serilog if (!fileLoggingConfiguration.Disable) - { - var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs"); - - logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log"); - services.AddLogging(builder => { + //common app data is C:/ProgramData on windows, else /usr/shar + var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs"); + + logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log"); + LogEventLevel? ConvertLogLevel(LogLevel logLevel) { switch (logLevel) @@ -499,44 +191,21 @@ namespace Tgstation.Server.Host.Core builder.AddSerilog(configuration.CreateLogger(), true); }); - } - services.AddScoped(); - - const string scheme = "JwtBearer"; - services.AddAuthentication((options) => + //configure bearer token validation + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => { - options.DefaultAuthenticateScheme = scheme; - options.DefaultChallengeScheme = scheme; - }).AddJwtBearer(scheme, jwtBearerOptions => - { - jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(TokenFactory.TokenSigningKey), - - ValidateIssuer = true, - ValidIssuer = TokenFactory.TokenIssuer, - - ValidateLifetime = true, - ValidateAudience = true, - ValidAudience = TokenFactory.TokenAudience, - - ClockSkew = TimeSpan.FromMinutes(1), - - RequireSignedTokens = true, - - RequireExpirationTime = true - }; + jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters; jwtBearerOptions.Events = new JwtBearerEvents { //Application is our composition root so this monstrosity of a line is okay OnTokenValidated = ctx => ctx.HttpContext.RequestServices.GetRequiredService().InjectClaimsIntoContext(ctx, ctx.HttpContext.RequestAborted) }; + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs }); - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs - + //add mvc, configure the json serializer settings services.AddMvc().AddJsonOptions(options => { options.AllowInputFormatterExceptionMessages = true; @@ -547,7 +216,6 @@ namespace Tgstation.Server.Host.Core options.SerializerSettings.Converters = new[] { new VersionConverter() }; }); - void AddTypedContext() where TContext : DatabaseContext { services.AddDbContext(builder => @@ -558,6 +226,7 @@ namespace Tgstation.Server.Host.Core services.AddScoped(x => x.GetRequiredService()); } + //add the correct database context type var dbType = databaseConfiguration.DatabaseType; switch (dbType) { @@ -572,18 +241,18 @@ namespace Tgstation.Server.Host.Core throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid {0}: {1}!", nameof(DatabaseType), dbType)); } - services.AddScoped(); - services.AddSingleton(); - - services.AddSingleton(); + //configure other database services + services.AddSingleton(); services.AddSingleton(); + + //configure security services + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton, PasswordHasher>(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - services.AddSingleton(); + //configure platform specific services if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { services.AddSingleton(); @@ -604,30 +273,29 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); } + //configure misc services + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(new ByondTopicSender { - ReceiveTimeout = 5000, - SendTimeout = 5000 + ReceiveTimeout = generalConfiguration.ByondTopicTimeout, + SendTimeout = generalConfiguration.ByondTopicTimeout }); + //configure component services + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + //configure root services services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); services.AddSingleton(x => x.GetRequiredService()); services.AddSingleton(); - - services.AddSingleton(ioManager); - - services.AddSingleton(); - services.AddSingleton(x => x.GetRequiredService()); - - services.AddSingleton(this); } /// @@ -650,21 +318,31 @@ namespace Tgstation.Server.Host.Core //attempt to restart the server if the configuration changes ChangeToken.OnChange(configuration.GetReloadToken, () => serverControl.Restart()); + //now setup the HTTP request pipeline + + //should anything after this throw an exception, catch it and display a detailed html page applicationBuilder.UseDeveloperExceptionPage(); //it is not worth it to limit this, you should only ever get it if you're an authorized user + //suppress OperationCancelledExceptions, they are just aborted HTTP requests applicationBuilder.UseCancelledRequestSuppression(); + //Do not service requests until Ready is called, this will return 503 until that point applicationBuilder.UseAsyncInitialization(async cancellationToken => { using (cancellationToken.Register(() => startupTcs.SetCanceled())) await startupTcs.Task.ConfigureAwait(false); }); + //authenticate JWT tokens using our security pipeline if present, returns 401 if bad applicationBuilder.UseAuthentication(); + //suppress and log database exceptions applicationBuilder.UseDbConflictHandling(); + //majority of handling is done in the controllers applicationBuilder.UseMvc(); + + //404 anything that gets this far } /// diff --git a/src/Tgstation.Server.Host/Core/ISetupWizard.cs b/src/Tgstation.Server.Host/Core/ISetupWizard.cs new file mode 100644 index 0000000000..bf8ddae948 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/ISetupWizard.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Core +{ + /// + /// The command line setup wizard + /// + interface ISetupWizard + { + /// + /// Run the setup wizard if necessary + /// + /// + /// + Task CheckRunWizard(CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs new file mode 100644 index 0000000000..182376fea4 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -0,0 +1,471 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MySql.Data.MySqlClient; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Data.SqlClient; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Configuration; +using Tgstation.Server.Host.IO; + +namespace Tgstation.Server.Host.Core +{ + /// + sealed class SetupWizard : ISetupWizard + { + /// + /// The for the + /// + readonly IIOManager ioManager; + + /// + /// The for the + /// + readonly IConsole console; + + /// + /// The for the + /// + readonly IHostingEnvironment hostingEnvironment; + + /// + /// The for the + /// + readonly IApplication application; + + /// + /// The for the + /// + readonly ILogger logger; + + /// + /// The for the + /// + readonly GeneralConfiguration generalConfiguration; + + /// + /// Construct a + /// + /// The value of + /// The value of + /// The value of + /// The value of + /// The containing the value of + public SetupWizard(IIOManager ioManager, IConsole console, IHostingEnvironment hostingEnvironment, IApplication application, ILogger logger, IOptions generalConfigurationOptions) + { + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.console = console ?? throw new ArgumentNullException(nameof(console)); + this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + this.application = application ?? throw new ArgumentNullException(nameof(application)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); + } + + /// + /// A prompt for a yes or no value + /// + /// The question + /// The for the operation + /// A resulting in if the user replied yes, otherwise + async Task PromptYesNo(string question, CancellationToken cancellationToken) + { + do + { + await console.WriteAsync(question, false, cancellationToken).ConfigureAwait(false); + var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + var upperResponse = responseString.ToUpperInvariant(); + if (upperResponse == "Y" || upperResponse == "YES") + return true; + else if (upperResponse == "N" || upperResponse == "NO") + return false; + await console.WriteAsync("Invalid response!", true, cancellationToken).ConfigureAwait(false); + } + while (true); + } + + /// + /// Prompts the user to enter the port to host TGS on + /// + /// The for the operation + /// A resulting in the hosting port, or to use the default + async Task PromptForHostingPort(CancellationToken cancellationToken) + { + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("What port would you like to connect to TGS on?", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Note: If this is a docker container with the default port already mapped, use the default.", true, cancellationToken).ConfigureAwait(false); + + do + { + await console.WriteAsync("API Port (leave blank for default): ", false, cancellationToken).ConfigureAwait(false); + var portString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(portString)) + return null; + if (UInt16.TryParse(portString, out var port) && port != 0) + return port; + await console.WriteAsync("Invalid port! Please enter a value between 1 and 65535", true, cancellationToken).ConfigureAwait(false); + } + while (true); + } + + /// + /// Prompts the user to create a + /// + /// The for the operation + /// A resulting in the new + async Task ConfigureDatabase(CancellationToken cancellationToken) + { + do + { + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("What SQL database type will you be using?", true, cancellationToken).ConfigureAwait(false); + + var databaseConfiguration = new DatabaseConfiguration(); + do + { + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Please enter one of {0}, {1}, or {2}: ", DatabaseType.MariaDB, DatabaseType.SqlServer, DatabaseType.MySql), false, cancellationToken).ConfigureAwait(false); + var databaseTypeString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (Enum.TryParse(databaseTypeString, out var databaseType)) + { + databaseConfiguration.DatabaseType = databaseType; + break; + } + await console.WriteAsync("Invalid database type!", true, cancellationToken).ConfigureAwait(false); + } + while (true); + + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Enter the server's address and port (blank for local): ", false, cancellationToken).ConfigureAwait(false); + var serverAddress = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(serverAddress)) + serverAddress = null; + + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Enter the database name (Can be from previous installation. Otherwise, should not exist): ", false, cancellationToken).ConfigureAwait(false); + var databaseName = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + + bool dbExists; + do + { + await console.WriteAsync("Does this database already exist? (y/n): ", false, cancellationToken).ConfigureAwait(false); + var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + var upperResponse = responseString.ToUpperInvariant(); + if (upperResponse == "Y" || upperResponse == "YES") + { + dbExists = true; + break; + } + else if (upperResponse == "N" || upperResponse == "NO") + { + dbExists = false; + break; + } + await console.WriteAsync("Invalid response!", true, cancellationToken).ConfigureAwait(false); + } + while (true); + + bool? useWinAuth; + if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + useWinAuth = await PromptYesNo("Use Windows Authentication? (y/n): ", cancellationToken).ConfigureAwait(false); + else + useWinAuth = null; + + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + + string username = null; + string password = null; + if (useWinAuth != true) + { + await console.WriteAsync("Enter username: ", false, cancellationToken).ConfigureAwait(false); + username = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Enter password: ", false, cancellationToken).ConfigureAwait(false); + password = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + } + + DbConnection testConnection; + if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer) + { + var csb = new SqlConnectionStringBuilder + { + ApplicationName = application.VersionPrefix, + DataSource = serverAddress ?? "(local)" + }; + if (useWinAuth.Value) + csb.IntegratedSecurity = true; + else + { + csb.UserID = username; + csb.Password = password; + } + testConnection = new SqlConnection + { + ConnectionString = csb.ConnectionString + }; + + csb.InitialCatalog = databaseName; + databaseConfiguration.ConnectionString = csb.ConnectionString; + } + else + { + var csb = new MySqlConnectionStringBuilder + { + Server = serverAddress ?? "127.0.0.1", + UserID = username, + Password = password + }; + testConnection = new MySqlConnection + { + ConnectionString = csb.ConnectionString + }; + csb.Database = databaseName; + databaseConfiguration.ConnectionString = csb.ConnectionString; + } + + try + { + using (testConnection) + { + await console.WriteAsync("Testing connection...", true, cancellationToken).ConfigureAwait(false); + await testConnection.OpenAsync(cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Connection successful!", true, cancellationToken).ConfigureAwait(false); + + if (databaseConfiguration.DatabaseType != DatabaseType.SqlServer) + { + await console.WriteAsync("Checking MySQL/MariaDB version...", true, cancellationToken).ConfigureAwait(false); + using (var command = testConnection.CreateCommand()) + { + command.CommandText = "SELECT VERSION()"; + var fullVersion = (string)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion), true, cancellationToken).ConfigureAwait(false); + var splits = fullVersion.Split('-'); + databaseConfiguration.MySqlServerVersion = splits[0]; + } + } + + if (!dbExists) + { + await console.WriteAsync("Testing create DB permission...", true, cancellationToken).ConfigureAwait(false); + using (var command = testConnection.CreateCommand()) + { + command.CommandText = String.Format(CultureInfo.InvariantCulture, "CREATE DATABASE {0}", databaseName); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + await console.WriteAsync("Success!", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Dropping test database...", true, cancellationToken).ConfigureAwait(false); + using (var command = testConnection.CreateCommand()) + { + command.CommandText = String.Format(CultureInfo.InvariantCulture, "DROP DATABASE {0}", databaseName); + try + { + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("This should be okay, but you may want to manually drop the database before continuing!", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Press any key to continue...", true, cancellationToken).ConfigureAwait(false); + await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false); + } + } + } + } + + return databaseConfiguration; + } + catch (Exception e) + { + await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Retrying database configuration...", true, cancellationToken).ConfigureAwait(false); + } + } while (true); + } + + /// + /// Prompts the user to create a + /// + /// The for the operation + /// A resulting in the new + async Task ConfigureGeneral(CancellationToken cancellationToken) + { + var generalConfiguration = new GeneralConfiguration(); + do + { + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", generalConfiguration.MinimumPasswordLength), false, cancellationToken).ConfigureAwait(false); + var passwordLengthString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(passwordLengthString)) + break; + if (UInt32.TryParse(passwordLengthString, out var passwordLength) && passwordLength >= 0) + { + generalConfiguration.MinimumPasswordLength = passwordLength; + break; + } + await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false); + } + while (true); + + do + { + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Timeout for sending and receiving BYOND topics (ms, 0 for infinite, leave blank for default of {0}): ", generalConfiguration.ByondTopicTimeout), false, cancellationToken).ConfigureAwait(false); + var topicTimeoutString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(topicTimeoutString)) + break; + if (Int32.TryParse(topicTimeoutString, out var topicTimeout) && topicTimeout >= 0) + { + generalConfiguration.ByondTopicTimeout = topicTimeout; + break; + } + await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false); + } + while (true); + + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Enter a GitHub personal access token to bypass some rate limits (this is optional and does not require any scopes)", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("GitHub personal access token: ", false, cancellationToken).ConfigureAwait(false); + generalConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(generalConfiguration.GitHubAccessToken)) + generalConfiguration.GitHubAccessToken = null; + return generalConfiguration; + } + + async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration generalConfiguration, CancellationToken cancellationToken) + { + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken).ConfigureAwait(false); + + var map = new Dictionary() + { + { DatabaseConfiguration.Section, databaseConfiguration }, + { GeneralConfiguration.Section, generalConfiguration } + }; + + if (hostingPort.HasValue) + map.Add("Kestrel", new + { + EndPoints = new + { + Http = new + { + Url = String.Format(CultureInfo.InvariantCulture, "http://0.0.0.0:{0}", hostingPort) + } + } + }); + + var json = JsonConvert.SerializeObject(map, Formatting.Indented); + var configBytes = Encoding.UTF8.GetBytes(json); + + try + { + await ioManager.WriteAllBytes(userConfigFileName, configBytes, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("For your convienence, here's the json we tried to write out:", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(json, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Press any key to exit...", true, cancellationToken).ConfigureAwait(false); + await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false); + throw new OperationCanceledException(); + } + + await console.WriteAsync("Waiting for configuration changes to reload...", true, cancellationToken).ConfigureAwait(false); + + //we need to wait for the configuration's file system watcher to read and reload the changes + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); + } + + /// + /// Runs the + /// + /// The path to the settings json to build + /// The for the operation + /// + async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken) + { + //welcome message + await console.WriteAsync("Welcome to tgstation-server 4!", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("This wizard will help you configure your server.", true, cancellationToken).ConfigureAwait(false); + + var hostingPort = await PromptForHostingPort(cancellationToken).ConfigureAwait(false); + + var databaseConfiguration = await ConfigureDatabase(cancellationToken).ConfigureAwait(false); + + var generalConfiguration = await ConfigureGeneral(cancellationToken).ConfigureAwait(false); + + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + + await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, generalConfiguration, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task CheckRunWizard(CancellationToken cancellationToken) + { + var setupWizardMode = generalConfiguration.SetupWizardMode; + logger.LogTrace("Checking if setup wizard should run. SetupWizardMode: {0}", setupWizardMode); + + if (setupWizardMode == SetupWizardMode.Never) + { + logger.LogTrace("Skipping due to configuration..."); + return false; + } + + var forceRun = setupWizardMode == SetupWizardMode.Force || setupWizardMode == SetupWizardMode.Only; + if (!console.Available) + { + if (forceRun) + throw new InvalidOperationException("Asked to run setup wizard with no console avaliable!"); + logger.LogTrace("Skipping due to console not being available..."); + return false; + } + + var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName); + var existenceTask = ioManager.FileExists(userConfigFileName, default); + var exists = existenceTask.GetAwaiter().GetResult(); + + bool shouldRunBasedOnAutodetect; + if (exists) + { + var readTask = ioManager.ReadAllBytes(userConfigFileName, default); + var bytes = readTask.GetAwaiter().GetResult(); + var contents = Encoding.UTF8.GetString(bytes); + var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents); + logger.LogTrace("Configuration json detected. Empty: {0}", existingConfigIsEmpty); + shouldRunBasedOnAutodetect = existingConfigIsEmpty; + } + else + { + shouldRunBasedOnAutodetect = true; + logger.LogTrace("No configuration json detected"); + } + + + if (!shouldRunBasedOnAutodetect) + { + if (forceRun) + { + logger.LogTrace("Asking user to bypass due to force run request..."); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "The configuration settings are requesting the setup wizard be run, but you already appear to have a configuration file ({0})!", userConfigFileName), true, cancellationToken).ConfigureAwait(false); + + forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken).ConfigureAwait(false); + } + if (!forceRun) + return false; + } + + await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false); + return true; + } + } +} diff --git a/src/Tgstation.Server.Host/IO/Console.cs b/src/Tgstation.Server.Host/IO/Console.cs new file mode 100644 index 0000000000..5c262b9f7e --- /dev/null +++ b/src/Tgstation.Server.Host/IO/Console.cs @@ -0,0 +1,59 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.IO +{ + /// + sealed class Console : IConsole + { + /// + public bool Available => Environment.UserInteractive; + + /// + public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() => System.Console.ReadKey(), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + + /// + public Task ReadLineAsync(bool usePasswordChar, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + //TODO Make this better: https://stackoverflow.com/questions/9479573/how-to-interrupt-console-readline + if (!usePasswordChar) + return System.Console.ReadLine(); + + var passwordBuilder = new StringBuilder(); + do + { + var keyDescription = System.Console.ReadKey(true); + if (keyDescription.Key == ConsoleKey.Enter) + break; + else if (keyDescription.Key == ConsoleKey.Backspace) + { + if (passwordBuilder.Length > 0) + { + --passwordBuilder.Length; + System.Console.Write("\b \b"); + } + } + else if (keyDescription.KeyChar != '\u0000') // KeyChar == '\u0000' if the key pressed does not correspond to a printable character, e.g. F1, Pause-Break, etc + { + passwordBuilder.Append(keyDescription.KeyChar); + System.Console.Write('*'); + } + } + while (!cancellationToken.IsCancellationRequested); + cancellationToken.ThrowIfCancellationRequested(); + System.Console.WriteLine(); + return passwordBuilder.ToString(); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + + /// + public Task WriteAsync(string text, bool newLine, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + if (newLine) + System.Console.WriteLine(text); + else + System.Console.Write(text); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + } +} diff --git a/src/Tgstation.Server.Host/IO/IConsole.cs b/src/Tgstation.Server.Host/IO/IConsole.cs new file mode 100644 index 0000000000..119b19d5dc --- /dev/null +++ b/src/Tgstation.Server.Host/IO/IConsole.cs @@ -0,0 +1,40 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.IO +{ + /// + /// Abstraction for + /// + interface IConsole + { + /// + /// If the is visible to the user + /// + bool Available { get; } + + /// + /// Write some to the + /// + /// The to write + /// If there should be a new line after the + /// The for the operation + /// A representing the running operation + Task WriteAsync(string text, bool newLine, CancellationToken cancellationToken); + + /// + /// Wait for a key press on the + /// + /// The for the operations + /// A representing the running operation + Task PressAnyKeyAsync(CancellationToken cancellationToken); + + /// + /// Read a line from the + /// + /// If the input should be retrieved using the '*' character + /// The for the operation + /// A resulting in the read by the + Task ReadLineAsync(bool usePasswordChar, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index cd67f0d876..0a459aaea1 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -1,4 +1,4 @@ -using System; +using Microsoft.IdentityModel.Tokens; using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Security @@ -8,6 +8,11 @@ namespace Tgstation.Server.Host.Security /// public interface ITokenFactory { + /// + /// The for the + /// + TokenValidationParameters ValidationParameters { get; } + /// /// Create a for a given /// diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index f9eb4d0f85..5b33554c40 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -16,9 +16,33 @@ namespace Tgstation.Server.Host.Security /// const int TokenExpiryMinutes = 15; - public static readonly string TokenAudience = typeof(Token).Assembly.GetName().Name; - public static readonly string TokenIssuer = Assembly.GetExecutingAssembly().GetName().Name; - public static readonly byte[] TokenSigningKey = CryptographySuite.GetSecureBytes(256); + /// + public TokenValidationParameters ValidationParameters { get; } + + /// + /// Construct a + /// + public TokenFactory() + { + ValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(CryptographySuite.GetSecureBytes(256)), + + ValidateIssuer = true, + ValidIssuer = Assembly.GetExecutingAssembly().GetName().Name, + + ValidateLifetime = true, + ValidateAudience = true, + ValidAudience = typeof(Token).Assembly.GetName().Name, + + ClockSkew = TimeSpan.FromMinutes(1), + + RequireSignedTokens = true, + + RequireExpirationTime = true + }; + } /// public Token CreateToken(Models.User user) @@ -32,13 +56,11 @@ namespace Tgstation.Server.Host.Security new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString(CultureInfo.InvariantCulture)), new Claim(JwtRegisteredClaimNames.Exp, $"{expiry.ToUnixTimeSeconds()}"), new Claim(JwtRegisteredClaimNames.Nbf, $"{DateTimeOffset.Now.ToUnixTimeSeconds()}"), - new Claim(JwtRegisteredClaimNames.Iss, TokenIssuer), - new Claim(JwtRegisteredClaimNames.Aud, TokenAudience) + new Claim(JwtRegisteredClaimNames.Iss, ValidationParameters.ValidIssuer), + new Claim(JwtRegisteredClaimNames.Aud, ValidationParameters.ValidAudience) }; - var key = new SymmetricSecurityKey(TokenSigningKey); - - var token = new JwtSecurityToken(new JwtHeader(new SigningCredentials(key, SecurityAlgorithms.HmacSha256)), new JwtPayload(claims)); + var token = new JwtSecurityToken(new JwtHeader(new SigningCredentials(ValidationParameters.IssuerSigningKey, SecurityAlgorithms.HmacSha256)), new JwtPayload(claims)); return new Token { Bearer = new JwtSecurityTokenHandler().WriteToken(token), ExpiresAt = expiry }; } } diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json index 51c0b4b686..2da5abc3b2 100644 --- a/src/Tgstation.Server.Host/appsettings.json +++ b/src/Tgstation.Server.Host/appsettings.json @@ -2,7 +2,8 @@ "General": { "MinimumPasswordLength": 15, "GitHubAccessToken": null, - "ConfigCheckOnly": false + "SetupWizardMode": "AutoDetect", + "ByondTopicTimeout": 5000 }, "FileLogging": { "Directory": null, //use the default path