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