From 58aa783e7cfec3e40538903ee6d1a7c0f7c8ac66 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 09:44:40 -0400 Subject: [PATCH 01/40] Remove unused using --- src/Tgstation.Server.Host/Core/Application.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 589a87a6da..b91c9493bc 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; From dca8422cd1f4e068402e7c6c1baae01839dd16ba Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 09:55:27 -0400 Subject: [PATCH 02/40] Ensure a production json is present in the Docker build --- build/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/Dockerfile b/build/Dockerfile index 240aeb77f9..222bb09da5 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -33,7 +33,7 @@ WORKDIR /app COPY --from=build /app . COPY --from=build /src/build/tgs.docker.sh tgs.sh -RUN mkdir /config_data +RUN mkdir /config_data && touch /config_data/appsettings.Production.json VOLUME ["/config_data", "/tgs_logs", "/app/lib"] ENTRYPOINT ["./tgs.sh"] From 40ccd210c20bdfe85c2c4a1fbd42d5c7a7d03221 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 11:04:57 -0400 Subject: [PATCH 03/40] Cleanup application configuration. --- src/Tgstation.Server.Host/Core/Application.cs | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index b91c9493bc..4221a24819 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; @@ -55,6 +56,9 @@ namespace Tgstation.Server.Host.Core /// readonly Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment; + /// + /// The used for determining when the is + /// readonly TaskCompletionSource startupTcs; /// @@ -71,6 +75,8 @@ namespace Tgstation.Server.Host.Core Version = Assembly.GetExecutingAssembly().GetName().Version; VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version); + + logger.LogInformation(VersionString); } /// @@ -83,19 +89,28 @@ namespace Tgstation.Server.Host.Core throw new ArgumentNullException(nameof(services)); services.Configure(configuration.GetSection(UpdatesConfiguration.Section)); - var databaseConfigurationSection = configuration.GetSection(DatabaseConfiguration.Section); - services.Configure(databaseConfigurationSection); + services.Configure(configuration.GetSection(DatabaseConfiguration.Section)); services.Configure(configuration.GetSection(GeneralConfiguration.Section)); + services.Configure(configuration.GetSection(FileLoggingConfiguration.Section)); + + services.AddOptions(); + + DatabaseConfiguration databaseConfiguration; + FileLoggingConfiguration fileLoggingConfiguration; + using (var provider = services.BuildServiceProvider()) + { + var dbOptions = provider.GetRequiredService>(); + databaseConfiguration = dbOptions.Value; + + var loggingOptions = provider.GetRequiredService>(); + fileLoggingConfiguration = loggingOptions.Value; + } - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var ioManager = new DefaultIOManager(); - //remember, anything you .Get manually can be null if the config is missing - var fileLoggingConfigurationSection = configuration.GetSection(FileLoggingConfiguration.Section); - var fileLoggingConfiguration = fileLoggingConfigurationSection.Get(); - if (fileLoggingConfiguration?.Disable != true) + if (!fileLoggingConfiguration.Disable) { - var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration?.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs"); + var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs"); logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log"); @@ -131,8 +146,8 @@ namespace Tgstation.Server.Host.Core } }; - var logEventLevel = ConvertLogLevel(GetMinimumLogLevel(fileLoggingConfiguration?.LogLevel)); - var microsoftEventLevel = ConvertLogLevel(GetMinimumLogLevel(fileLoggingConfiguration?.MicrosoftLogLevel)); + var logEventLevel = ConvertLogLevel(GetMinimumLogLevel(fileLoggingConfiguration.LogLevel)); + var microsoftEventLevel = ConvertLogLevel(GetMinimumLogLevel(fileLoggingConfiguration.MicrosoftLogLevel)); var formatter = new MessageTemplateTextFormatter("{Timestamp:o} {RequestId,13} [{Level:u3}] {SourceContext:l}: {Message} ({EventId:x8}){NewLine}{Exception}", null); @@ -150,8 +165,6 @@ namespace Tgstation.Server.Host.Core }); } - services.AddOptions(); - services.AddScoped(); const string scheme = "JwtBearer"; @@ -198,7 +211,6 @@ namespace Tgstation.Server.Host.Core options.SerializerSettings.Converters = new[] { new VersionConverter() }; }); - var databaseConfiguration = databaseConfigurationSection.Get(); void AddTypedContext() where TContext : DatabaseContext { @@ -210,8 +222,8 @@ namespace Tgstation.Server.Host.Core services.AddScoped(x => x.GetRequiredService()); } - var dbType = databaseConfiguration?.DatabaseType; - switch (databaseConfiguration?.DatabaseType) + var dbType = databaseConfiguration.DatabaseType; + switch (dbType) { case DatabaseType.MySql: case DatabaseType.MariaDB: @@ -236,7 +248,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); - if (isWindows) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { services.AddSingleton(); services.AddSingleton(); @@ -298,7 +310,7 @@ namespace Tgstation.Server.Host.Core throw new ArgumentNullException(nameof(serverControl)); logger.LogInformation(VersionString); - + //attempt to restart the server if the configuration changes ChangeToken.OnChange(configuration.GetReloadToken, () => serverControl.Restart()); From 616c047e893c42cc70ccee9e38ed69f48221a95b Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 11:06:12 -0400 Subject: [PATCH 04/40] Remove appsettings.Development.json from source control --- .gitignore | 1 + src/Tgstation.Server.Host/Tgstation.Server.Host.csproj | 3 --- src/Tgstation.Server.Host/appsettings.Development.json | 5 ----- 3 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 src/Tgstation.Server.Host/appsettings.Development.json diff --git a/.gitignore b/.gitignore index a8f3e2c779..20bc8fcd5a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ artifacts/ /tests/DMAPI/travistester.lk /tests/DMAPI/travistester.int /tests/DMAPI/travistester.dmb +/src/Tgstation.Server.Host/appsettings.Development.json diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 9a058b87d8..0d716f4321 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -68,9 +68,6 @@ - - PreserveNewest - PreserveNewest diff --git a/src/Tgstation.Server.Host/appsettings.Development.json b/src/Tgstation.Server.Host/appsettings.Development.json deleted file mode 100644 index 5510b3cca2..0000000000 --- a/src/Tgstation.Server.Host/appsettings.Development.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "FileLogging": { - "Disable": true - } -} From 09a6c170083786b3f4dc1b20525979a7815c3e70 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 11:06:24 -0400 Subject: [PATCH 05/40] Fix compile --- src/Tgstation.Server.Host/Core/Application.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 4221a24819..e98bd91400 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -75,8 +75,6 @@ namespace Tgstation.Server.Host.Core Version = Assembly.GetExecutingAssembly().GetName().Version; VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version); - - logger.LogInformation(VersionString); } /// From 7be7a6a90a9bd41d72b9fdcafd36eb87b0bdd304 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 14:57:50 -0400 Subject: [PATCH 06/40] Setup wizard --- .../Configuration/GeneralConfiguration.cs | 5 + src/Tgstation.Server.Host/Core/Application.cs | 344 +++++++++++++++++- src/Tgstation.Server.Host/appsettings.json | 3 +- 3 files changed, 350 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index ccf366863c..b810583507 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -19,5 +19,10 @@ /// A GitHub personal access token to use for bypassing rate limits on requests. Requires no scopes /// public string GitHubAccessToken { get; set; } + + /// + /// If the should just check if needs to be run and then exit + /// + public bool ConfigCheckOnly { get; set; } } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index e98bd91400..294e025ead 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -11,16 +11,21 @@ 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; @@ -77,6 +82,331 @@ 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("Port (leave blank for default of 5000): "); + 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.Replace(";User Id=", ";uid=", StringComparison.Ordinal).Replace(";Password=", ";Pwd=", StringComparison.Ordinal); //stupidity + } + + 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 = Console.ReadLine(); + 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(); + } + + return true; + } + /// /// Configure dependency injected services /// @@ -93,10 +423,23 @@ namespace Tgstation.Server.Host.Core services.AddOptions(); + var ioManager = new DefaultIOManager(); + var ranWizard = CheckRunSetupWizard(ioManager); + + GeneralConfiguration generalConfiguration; DatabaseConfiguration databaseConfiguration; FileLoggingConfiguration fileLoggingConfiguration; using (var provider = services.BuildServiceProvider()) { + var generalOptions = provider.GetRequiredService>(); + generalConfiguration = generalOptions.Value; + + if (generalConfiguration.ConfigCheckOnly) + throw new OperationCanceledException("Configuration check complete!"); + + if (ranWizard) + Console.WriteLine("Now launching TGS..."); + var dbOptions = provider.GetRequiredService>(); databaseConfiguration = dbOptions.Value; @@ -104,7 +447,6 @@ namespace Tgstation.Server.Host.Core fileLoggingConfiguration = loggingOptions.Value; } - var ioManager = new DefaultIOManager(); if (!fileLoggingConfiguration.Disable) { diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json index e24be80260..51c0b4b686 100644 --- a/src/Tgstation.Server.Host/appsettings.json +++ b/src/Tgstation.Server.Host/appsettings.json @@ -1,7 +1,8 @@ { "General": { "MinimumPasswordLength": 15, - "GitHubAccessToken": null + "GitHubAccessToken": null, + "ConfigCheckOnly": false }, "FileLogging": { "Directory": null, //use the default path From f0c9227a2c5459516194116363e67bf2b2884c6d Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 15:54:20 -0400 Subject: [PATCH 07/40] Add wizard support to service --- src/Tgstation.Server.Host.Console/Program.cs | 2 +- src/Tgstation.Server.Host.Service/Program.cs | 71 +++++++++++-------- .../ServerService.cs | 4 +- .../IWatchdog.cs | 3 +- .../Watchdog.cs | 15 +++- .../Configuration/GeneralConfiguration.cs | 2 +- .../TestProgram.cs | 2 +- .../TestServerService.cs | 2 +- 8 files changed, 64 insertions(+), 37 deletions(-) diff --git a/src/Tgstation.Server.Host.Console/Program.cs b/src/Tgstation.Server.Host.Console/Program.cs index 12da56b3b3..3e9b566774 100644 --- a/src/Tgstation.Server.Host.Console/Program.cs +++ b/src/Tgstation.Server.Host.Console/Program.cs @@ -48,7 +48,7 @@ namespace Tgstation.Server.Host.Console b.Cancel = true; cts.Cancel(); }; - await WatchdogFactory.CreateWatchdog(loggerFactory).RunAsync(arguments.ToArray(), cts.Token).ConfigureAwait(false); + await WatchdogFactory.CreateWatchdog(loggerFactory).RunAsync(false, arguments.ToArray(), cts.Token).ConfigureAwait(false); } finally { diff --git a/src/Tgstation.Server.Host.Service/Program.cs b/src/Tgstation.Server.Host.Service/Program.cs index e34dcc7c1e..1b38650650 100644 --- a/src/Tgstation.Server.Host.Service/Program.cs +++ b/src/Tgstation.Server.Host.Service/Program.cs @@ -31,6 +31,12 @@ namespace Tgstation.Server.Host.Service [Option(ShortName = "i")] public bool Install { get; set; } + /// + /// The --configure or -c option + /// + [Option(ShortName = "c")] + public bool Configure { get; set; } + /// /// The --trace or -t option. Enables trace logs /// @@ -43,6 +49,8 @@ namespace Tgstation.Server.Host.Service [Option(ShortName = "d")] public bool Debug { get; set; } + static readonly IWatchdogFactory watchdogFactory = new WatchdogFactory(); + /// /// Check if the running user is a system administrator /// @@ -63,7 +71,7 @@ namespace Tgstation.Server.Host.Service { if (!Install && !Uninstall) { - var result = MessageBox.Show("You are running the TGS windows service executable directly. It should only be run by the service control manager. Would you like to install the service in this location?", "TGS Service", MessageBoxButtons.YesNo); + var result = MessageBox.Show("You are running the TGS windows service executable directly. It should only be run by the service control manager. Would you like to install and configure the service in this location?", "TGS Service", MessageBoxButtons.YesNo); if (result != DialogResult.Yes) return; Install = true; @@ -76,7 +84,7 @@ namespace Tgstation.Server.Host.Service { UseShellExecute = true, Verb = "runas", - Arguments = Install ? "-i" : "-u", + Arguments = Install ? "-i -c" : "-u", FileName = exe, WorkingDirectory = Environment.CurrentDirectory, }; @@ -84,39 +92,44 @@ namespace Tgstation.Server.Host.Service return; } - if (Install) + using (var loggerFactory = new LoggerFactory()) { - if (Uninstall) - //oh no, it's retarded... - return; - using (var processInstaller = new ServiceProcessInstaller()) - using (var installer = new ServiceInstaller()) + if (Configure) + watchdogFactory.CreateWatchdog(loggerFactory).RunAsync(true, Array.Empty(), default); + + if (Install) { - processInstaller.Account = ServiceAccount.LocalSystem; + if (Uninstall) + //oh no, it's retarded... + return; + using (var processInstaller = new ServiceProcessInstaller()) + using (var installer = new ServiceInstaller()) + { + processInstaller.Account = ServiceAccount.LocalSystem; - installer.Context = new InstallContext("tgs-4-install.log", new string[] { String.Format(CultureInfo.InvariantCulture, "/assemblypath={0}", Assembly.GetEntryAssembly().Location) }); - installer.Description = "/tg/station 13 server v4 running as a windows service"; - installer.DisplayName = "/tg/station server 4"; - installer.DelayedAutoStart = true; - installer.StartType = ServiceStartMode.Automatic; - installer.ServicesDependedOn = new string[] { "Tcpip", "Dhcp", "Dnscache" }; - installer.ServiceName = ServerService.Name; - installer.Parent = processInstaller; + installer.Context = new InstallContext("tgs-4-install.log", new string[] { String.Format(CultureInfo.InvariantCulture, "/assemblypath={0}", Assembly.GetEntryAssembly().Location) }); + installer.Description = "/tg/station 13 server v4 running as a windows service"; + installer.DisplayName = "/tg/station server 4"; + installer.DelayedAutoStart = true; + installer.StartType = ServiceStartMode.Automatic; + installer.ServicesDependedOn = new string[] { "Tcpip", "Dhcp", "Dnscache" }; + installer.ServiceName = ServerService.Name; + installer.Parent = processInstaller; - var state = new ListDictionary(); - installer.Install(state); + var state = new ListDictionary(); + installer.Install(state); + } } + else if (Uninstall) + using (var installer = new ServiceInstaller()) + { + installer.Context = new InstallContext("tgs-4-uninstall.log", null); + installer.ServiceName = ServerService.Name; + installer.Uninstall(null); + } + else if(!Configure) + ServiceBase.Run(new ServerService(watchdogFactory, loggerFactory, Trace ? LogLevel.Trace : Debug ? LogLevel.Debug : LogLevel.Information)); } - else if (Uninstall) - using (var installer = new ServiceInstaller()) - { - installer.Context = new InstallContext("tgs-4-uninstall.log", null); - installer.ServiceName = ServerService.Name; - installer.Uninstall(null); - } - else - using (var loggerFactory = new LoggerFactory()) - ServiceBase.Run(new ServerService(new WatchdogFactory(), loggerFactory, Trace ? LogLevel.Trace : Debug ? LogLevel.Debug : LogLevel.Information)); } /// diff --git a/src/Tgstation.Server.Host.Service/ServerService.cs b/src/Tgstation.Server.Host.Service/ServerService.cs index 948200703d..1d34049e31 100644 --- a/src/Tgstation.Server.Host.Service/ServerService.cs +++ b/src/Tgstation.Server.Host.Service/ServerService.cs @@ -27,7 +27,7 @@ namespace Tgstation.Server.Host.Service readonly IWatchdog watchdog; /// - /// The recieved from of + /// The recieved from of /// Task watchdogTask; @@ -78,7 +78,7 @@ namespace Tgstation.Server.Host.Service { cancellationTokenSource?.Dispose(); cancellationTokenSource = new CancellationTokenSource(); - watchdogTask = watchdog.RunAsync(args, cancellationTokenSource.Token); + watchdogTask = watchdog.RunAsync(false, args, cancellationTokenSource.Token); } /// diff --git a/src/Tgstation.Server.Host.Watchdog/IWatchdog.cs b/src/Tgstation.Server.Host.Watchdog/IWatchdog.cs index 5ebd252dab..81333440d6 100644 --- a/src/Tgstation.Server.Host.Watchdog/IWatchdog.cs +++ b/src/Tgstation.Server.Host.Watchdog/IWatchdog.cs @@ -11,9 +11,10 @@ namespace Tgstation.Server.Host.Watchdog /// /// Run the /// + /// If the should just run the host configuration wizard and exit /// The arguments for the /// The for the operation /// A representing the running operation - Task RunAsync(string[] args, CancellationToken cancellationToken); + Task RunAsync(bool runConfigure, string[] args, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs index 1dbc341140..3122ed5fce 100644 --- a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs @@ -31,7 +31,7 @@ namespace Tgstation.Server.Host.Watchdog } /// - public async Task RunAsync(string[] args, CancellationToken cancellationToken) + public async Task RunAsync(bool runConfigure, string[] args, CancellationToken cancellationToken) { logger.LogInformation("Host watchdog starting..."); logger.LogDebug("PID: {0}", Process.GetCurrentProcess().Id); @@ -127,6 +127,13 @@ namespace Tgstation.Server.Host.Watchdog if (Environment.GetCommandLineArgs().Any(x => x == "--attach-host-debugger")) arguments.Add("--attach-debugger"); + + if (runConfigure) + { + logger.LogInformation("Running configuration check and wizard if necessary..."); + arguments.Add("General:ConfigCheckOnly=true"); + } + arguments.AddRange(args); process.StartInfo.Arguments = String.Join(" ", arguments); @@ -185,6 +192,12 @@ namespace Tgstation.Server.Host.Watchdog logger.LogInformation("Host exited!"); } + if (runConfigure) + { + logger.LogInformation("Exiting due to configuration check..."); + return; + } + switch (process.ExitCode) { case 0: diff --git a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index b810583507..53509172bb 100644 --- a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs @@ -21,7 +21,7 @@ public string GitHubAccessToken { get; set; } /// - /// If the should just check if needs to be run and then exit + /// If the should just check if the configuration wizard needs to be run and then exit /// public bool ConfigCheckOnly { get; set; } } diff --git a/tests/Tgstation.Server.Host.Console.Tests/TestProgram.cs b/tests/Tgstation.Server.Host.Console.Tests/TestProgram.cs index 695d75a619..5eca82b110 100644 --- a/tests/Tgstation.Server.Host.Console.Tests/TestProgram.cs +++ b/tests/Tgstation.Server.Host.Console.Tests/TestProgram.cs @@ -16,7 +16,7 @@ namespace Tgstation.Server.Host.Console.Tests { var mockServer = new Mock(); var args = Array.Empty(); - mockServer.Setup(x => x.RunAsync(args, It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + mockServer.Setup(x => x.RunAsync(false, args, It.IsAny())).Returns(Task.CompletedTask).Verifiable(); var mockServerFactory = new Mock(); mockServerFactory.Setup(x => x.CreateWatchdog(It.IsAny())).Returns(mockServer.Object).Verifiable(); Program.WatchdogFactory = mockServerFactory.Object; diff --git a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs index 36d68f59c3..ec6c92bb5e 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs +++ b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs @@ -35,7 +35,7 @@ namespace Tgstation.Server.Host.Service.Tests var mockWatchdog = new Mock(); var args = Array.Empty(); CancellationToken cancellationToken; - mockWatchdog.Setup(x => x.RunAsync(args, It.IsAny())).Callback((string[] _, CancellationToken token) => cancellationToken = token).Returns(Task.CompletedTask).Verifiable(); + mockWatchdog.Setup(x => x.RunAsync(false, args, It.IsAny())).Callback((string[] _, CancellationToken token) => cancellationToken = token).Returns(Task.CompletedTask).Verifiable(); var mockWatchdogFactory = new Mock(); var mockLoggerFactory = new LoggerFactory(); mockWatchdogFactory.Setup(x => x.CreateWatchdog(mockLoggerFactory)).Returns(mockWatchdog.Object).Verifiable(); From 33e2152cd3b2c4670c8fd1b7f54dd6305d5c3a6f Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 16:11:40 -0400 Subject: [PATCH 08/40] Minor setup wizard fixes --- src/Tgstation.Server.Host/Core/Application.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 294e025ead..6758b44e53 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -273,7 +273,7 @@ namespace Tgstation.Server.Host.Core ConnectionString = csb.ConnectionString }; csb.Database = databaseName; - databaseConfiguration.ConnectionString = csb.ConnectionString.Replace(";User Id=", ";uid=", StringComparison.Ordinal).Replace(";Password=", ";Pwd=", StringComparison.Ordinal); //stupidity + databaseConfiguration.ConnectionString = csb.ConnectionString; } try @@ -359,7 +359,7 @@ namespace Tgstation.Server.Host.Core 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 = Console.ReadLine(); + generalConfiguration.GitHubAccessToken = GetPassword(); if (String.IsNullOrWhiteSpace(generalConfiguration.GitHubAccessToken)) generalConfiguration.GitHubAccessToken = null; @@ -404,6 +404,9 @@ namespace Tgstation.Server.Host.Core throw new OperationCanceledException(); } + Console.WriteLine("Waiting for configuration changes to reload..."); + Task.Delay(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); + return true; } From 4828af61d68c8cf99257a457f3299d4725132cfc Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 16:15:15 -0400 Subject: [PATCH 09/40] Add a note about the configuration wizard to the README --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6c02c8f7ca..bbe606c69b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ tgstation-server supports running in a docker container and is the recommended d To create a container run ```sh -docker create \ +docker run \ + -ti \ #start interactive for manual configuration --restart=always \ #if you want maximum uptime --network="host" \ #if your sql server is on the same machine --name="tgs" \ #or whatever else you wanna call it @@ -51,7 +52,7 @@ docker create \ -p :80 \ -p 0.0.0.0:: \ -v /path/to/store/instances:/tgs4_instances \ - -v /path/to/your/appsettings.Production.json:/config_data \ + -v /path/to/your/appsettings.Production.json:/config_data \ #only if you want to use manual configuration -v path/to/your/log/folder:/tgs_logs \ tgstation/server ``` @@ -59,10 +60,18 @@ with any additional options you desire (i.e. You'll have to expose more game por Note although `/app/lib` is specified as a volume mount point in the `Dockerfile`, unless you REALLY know what you're doing. Do not mount any volumes over this for fear of breaking your container. -Before starting your container make sure the aforemention `appsettings.Production.json` is configured properly. See below +If using manual configuration, before starting your container make sure the aforemention `appsettings.Production.json` is setup properly. See below ### Configuring +The first time you run TGS4 you should be prompted with a configuration wizard which will guide you through setting up your appsettings.Production.json + +![](https://user-images.githubusercontent.com/8171642/46436355-99ee0e00-c726-11e8-82fa-6626b2503a6c.png) + +This wizard will run whenever the server is launched without detecting the config json. Follow the instructions below to perform this process manually. + +#### Manual Configuration + Create an `appsettings.Production.json` file next to `appsettings.json`. This will override the default settings in appsettings.json with your production settings. There are a few keys meant to be changed by hosts. Modifying any config files while the server is running will trigger a safe restart (Keeps DreamDaemon's running). Note these are all case-sensitive: - `General:MinimumPasswordLength`: Minimum password length requirement for database users From d5009c368b0225db2c43ca1ec4f8fea44ed3c15f Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 16:22:39 -0400 Subject: [PATCH 10/40] Change misleading port input message It's port 80 default in docker --- src/Tgstation.Server.Host/Core/Application.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 6758b44e53..0ae3791abd 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -111,7 +111,7 @@ namespace Tgstation.Server.Host.Core ushort? port = null; do { - Console.Write("Port (leave blank for default of 5000): "); + Console.Write("API Port (leave blank for default): "); var portString = Console.ReadLine(); if (String.IsNullOrWhiteSpace(portString)) break; From 8df0e8fddf037475481133cd4e306db697d2864f Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Wed, 3 Oct 2018 16:25:47 -0400 Subject: [PATCH 11/40] Add icon to service project --- .../Tgstation.Server.Host.Service.csproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj index 20eccf1840..c259de621b 100644 --- a/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj +++ b/src/Tgstation.Server.Host.Service/Tgstation.Server.Host.Service.csproj @@ -58,6 +58,9 @@ Tgstation.Server.Host.Service.Program + + ../../build/tgs.ico + From 2cfa49c5f40784e5593b6d8df579c9b64163eb7a Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Thu, 4 Oct 2018 00:38:31 -0400 Subject: [PATCH 12/40] Fix failing test --- tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs index ec6c92bb5e..4cbfc16d8e 100644 --- a/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs +++ b/tests/Tgstation.Server.Host.Service.Tests/TestServerService.cs @@ -35,7 +35,7 @@ namespace Tgstation.Server.Host.Service.Tests var mockWatchdog = new Mock(); var args = Array.Empty(); CancellationToken cancellationToken; - mockWatchdog.Setup(x => x.RunAsync(false, args, It.IsAny())).Callback((string[] _, CancellationToken token) => cancellationToken = token).Returns(Task.CompletedTask).Verifiable(); + mockWatchdog.Setup(x => x.RunAsync(false, args, It.IsAny())).Callback((bool x, string[] _, CancellationToken token) => cancellationToken = token).Returns(Task.CompletedTask).Verifiable(); var mockWatchdogFactory = new Mock(); var mockLoggerFactory = new LoggerFactory(); mockWatchdogFactory.Setup(x => x.CreateWatchdog(mockLoggerFactory)).Returns(mockWatchdog.Object).Verifiable(); From 21a538d4c6e6a99a55e0e855cad619323e752f65 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 09:46:27 -0400 Subject: [PATCH 13/40] Serialize enum config values as strings --- .../Configuration/DatabaseConfiguration.cs | 6 +++++- .../Configuration/FileLoggingConfiguration.cs | 12 +++++++++--- src/Tgstation.Server.Host/Core/Application.cs | 11 ++--------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs b/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs index d5c4a34359..d1d2bfec97 100644 --- a/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/DatabaseConfiguration.cs @@ -1,4 +1,7 @@ -namespace Tgstation.Server.Host.Configuration +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Tgstation.Server.Host.Configuration { /// /// Configuration options for the @@ -13,6 +16,7 @@ /// /// The to create /// + [JsonConverter(typeof(StringEnumConverter))] public DatabaseType DatabaseType { get; set; } /// diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 7650a7573e..2b731639cc 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -1,4 +1,8 @@ -namespace Tgstation.Server.Host.Configuration +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Tgstation.Server.Host.Configuration { /// /// File logging configuration options @@ -23,12 +27,14 @@ /// /// The ified minimum to display in logs /// - public string LogLevel { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public LogLevel LogLevel { get; set; } /// /// The ified minimum to display in logs for Microsoft library sources /// - public string MicrosoftLogLevel { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public LogLevel MicrosoftLogLevel { get; set; } } } diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 0ae3791abd..31237dbbb7 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -459,13 +459,6 @@ namespace Tgstation.Server.Host.Core services.AddLogging(builder => { - LogLevel GetMinimumLogLevel(string stringLevel) - { - if (String.IsNullOrWhiteSpace(stringLevel) || !Enum.TryParse(stringLevel, out var minimumLevel)) - minimumLevel = LogLevel.Information; - return minimumLevel; - } - LogEventLevel? ConvertLogLevel(LogLevel logLevel) { switch (logLevel) @@ -489,8 +482,8 @@ namespace Tgstation.Server.Host.Core } }; - var logEventLevel = ConvertLogLevel(GetMinimumLogLevel(fileLoggingConfiguration.LogLevel)); - var microsoftEventLevel = ConvertLogLevel(GetMinimumLogLevel(fileLoggingConfiguration.MicrosoftLogLevel)); + var logEventLevel = ConvertLogLevel(fileLoggingConfiguration.LogLevel); + var microsoftEventLevel = ConvertLogLevel(fileLoggingConfiguration.MicrosoftLogLevel); var formatter = new MessageTemplateTextFormatter("{Timestamp:o} {RequestId,13} [{Level:u3}] {SourceContext:l}: {Message} ({EventId:x8}){NewLine}{Exception}", null); From 5d179653e0463879ed85c8b84ef200f0312fad8a Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 12:28:26 -0400 Subject: [PATCH 14/40] 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 From 62c42dfbfe1bd21ecf068539f11d12ce11372dbe Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 12:29:47 -0400 Subject: [PATCH 15/40] Remove setup wizard from integration tests --- tests/Tgstation.Server.Tests/TestingServer.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Tgstation.Server.Tests/TestingServer.cs b/tests/Tgstation.Server.Tests/TestingServer.cs index 50a2691860..c57637f7b3 100644 --- a/tests/Tgstation.Server.Tests/TestingServer.cs +++ b/tests/Tgstation.Server.Tests/TestingServer.cs @@ -6,6 +6,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Host; +using Tgstation.Server.Host.Configuration; namespace Tgstation.Server.Tests { @@ -42,7 +43,8 @@ namespace Tgstation.Server.Tests String.Format(CultureInfo.InvariantCulture, "Kestrel:EndPoints:Http:Url={0}", Url), String.Format(CultureInfo.InvariantCulture, "Database:DatabaseType={0}", databaseType), String.Format(CultureInfo.InvariantCulture, "Database:ConnectionString={0}", connectionString), - "Database:DropDatabase=true" + String.Format(CultureInfo.InvariantCulture, "Database:DropDatabase={0}", true), + String.Format(CultureInfo.InvariantCulture, "General:SetupWizardMode={0}", SetupWizardMode.Never) }; if (!String.IsNullOrEmpty(gitHubAccessToken)) From b971a17aac5e3da5f0e5fc3ef7bf23d1800adb1f Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 13:03:56 -0400 Subject: [PATCH 16/40] Fix JWT configuration --- src/Tgstation.Server.Host/Core/Application.cs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index c7b55d8bdc..e191c914e5 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -97,22 +97,21 @@ namespace Tgstation.Server.Host.Core //enable options which give us config reloading services.AddOptions(); - - //need this instance for later configuration - var ioManager = new DefaultIOManager(); - + //setup stuff for setup wizard - services.AddSingleton(ioManager); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); //needed here for JWT configuration - services.AddSingleton(); + //we use a manually instatiated token factory to prevent it from regenerating the signing key after we configure it + services.AddSingleton(new TokenFactory()); GeneralConfiguration generalConfiguration; DatabaseConfiguration databaseConfiguration; FileLoggingConfiguration fileLoggingConfiguration; ITokenFactory tokenFactory; + IIOManager ioManager; //temporarily build the service provider in it's current state //do it here so we can run the setup wizard if necessary @@ -140,6 +139,7 @@ namespace Tgstation.Server.Host.Core fileLoggingConfiguration = loggingOptions.Value; tokenFactory = provider.GetRequiredService(); + ioManager = provider.GetRequiredService(); } //setup file logging via serilog @@ -193,7 +193,11 @@ namespace Tgstation.Server.Host.Core }); //configure bearer token validation - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => + services.AddAuthentication((options) => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => { jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters; jwtBearerOptions.Events = new JwtBearerEvents @@ -201,9 +205,10 @@ namespace Tgstation.Server.Host.Core //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 }); + //fucking converts 'sub' to M$ bs + //can't be done in the above lambda, that's too late + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //add mvc, configure the json serializer settings services.AddMvc().AddJsonOptions(options => From 7d02004ca68c233ff08f7651fe5e033f06562c1e Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 13:09:24 -0400 Subject: [PATCH 17/40] Add a token fuckup test --- tests/Tgstation.Server.Tests/IntegrationTest.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/Tgstation.Server.Tests/IntegrationTest.cs b/tests/Tgstation.Server.Tests/IntegrationTest.cs index f90a7e9151..bc8eb4ccc6 100644 --- a/tests/Tgstation.Server.Tests/IntegrationTest.cs +++ b/tests/Tgstation.Server.Tests/IntegrationTest.cs @@ -53,6 +53,16 @@ namespace Tgstation.Server.Tests Assert.AreEqual(ApiHeaders.Version, serverInfo.ApiVersion); Assert.AreEqual(typeof(IServer).Assembly.GetName().Version, serverInfo.Version); + //check that modifying the token even slightly fucks up the auth + var newToken = new Token + { + ExpiresAt = adminClient.Token.ExpiresAt, + Bearer = adminClient.Token.Bearer + '0' + }; + + var badClient = clientFactory.CreateServerClient(server.Url, newToken); + await Assert.ThrowsExceptionAsync(() => badClient.Version(cancellationToken)).ConfigureAwait(false); + await new AdministrationTest(adminClient.Administration).Run(cancellationToken).ConfigureAwait(false); await new InstanceManagerTest(adminClient.Instances, server.Directory).Run(cancellationToken).ConfigureAwait(false); } From fc260ef29189baaf8d8ce2892e0d3b5a905d57af Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 13:11:07 -0400 Subject: [PATCH 18/40] Remove redundancy --- src/Tgstation.Server.Host/Core/Application.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index e191c914e5..e0182463a2 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -193,11 +193,7 @@ namespace Tgstation.Server.Host.Core }); //configure bearer token validation - services.AddAuthentication((options) => - { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions => + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(jwtBearerOptions => { jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters; jwtBearerOptions.Events = new JwtBearerEvents From 137626c950c182b8fe149ebc3acdb410537ab798 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 14:19:23 -0400 Subject: [PATCH 19/40] Add a basic application test --- .../Core/TestApplication.cs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs new file mode 100644 index 0000000000..3aa267752c --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; + +namespace Tgstation.Server.Host.Core.Tests +{ + [TestClass] + public sealed class TestApplication + { + [TestMethod] + public void TestMethodThrows() + { + Assert.ThrowsException(() => new Application(null, null)); + var mockConfiguration = new Mock(); + Assert.ThrowsException(() => new Application(mockConfiguration.Object, null)); + + var mockHostingEnvironment = new Mock(); + + var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object); + + Assert.ThrowsException(() => app.ConfigureServices(null)); + Assert.ThrowsException(() => app.Configure(null, null, null)); + + var mockAppBuilder = new Mock(); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, null, null)); + + var mockLogger = new Mock>(); + Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockLogger.Object, null)); + } + } +} From 81af83da75aea1192ce0f938d3760a35b67fb32a Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 15:36:56 -0400 Subject: [PATCH 20/40] Some application tests --- .../Core/TestApplication.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs index 3aa267752c..f3747f0fe7 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs @@ -1,13 +1,22 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Configuration; namespace Tgstation.Server.Host.Core.Tests { + /// + /// Tests for + /// [TestClass] public sealed class TestApplication { @@ -31,5 +40,58 @@ namespace Tgstation.Server.Host.Core.Tests var mockLogger = new Mock>(); Assert.ThrowsException(() => app.Configure(mockAppBuilder.Object, mockLogger.Object, null)); } + + class MockSetupWizard : ISetupWizard + { + public Task CheckRunWizard(CancellationToken cancellationToken) => Task.FromResult(true); + } + + class MockApplicationLifetime : IApplicationLifetime + { + public CancellationToken ApplicationStarted => default; + + public CancellationToken ApplicationStopping => default; + + public CancellationToken ApplicationStopped => default; + + public void StopApplication() { } + } + + [TestMethod] + public void TestConfigureServicesThrowsWhenSetupWizardConfigurationDemands() + { + var mockConfiguration = new Mock(); + Assert.ThrowsException(() => new Application(mockConfiguration.Object, null)); + + var mockHostingEnvironment = new Mock(); + + var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object); + + var mockOptions = new Mock>(); + mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration + { + SetupWizardMode = SetupWizardMode.Only + }).Verifiable(); + + var fakeServiceDescriptor = new List() + { + new ServiceDescriptor(typeof(IApplicationLifetime), typeof(MockApplicationLifetime), ServiceLifetime.Singleton), + new ServiceDescriptor(typeof(ISetupWizard), typeof(MockSetupWizard), ServiceLifetime.Singleton), + new ServiceDescriptor(typeof(IOptions), mockOptions.Object) + }; + + var mockServiceCollection = new Mock(); + + var mockConfigSection = new Mock(); + + mockConfiguration.Setup(x => x.GetSection(It.IsNotNull())).Returns(mockConfigSection.Object).Verifiable(); + mockServiceCollection.Setup(x => x.GetEnumerator()).Returns(() => fakeServiceDescriptor.GetEnumerator()).Verifiable(); + + Assert.ThrowsException(() => app.ConfigureServices(mockServiceCollection.Object)); + + mockOptions.VerifyAll(); + mockConfiguration.VerifyAll(); + mockServiceCollection.VerifyAll(); + } } } From d7bff52b6b7fd61b28b3d8436e64294ea83d313d Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 16:02:20 -0400 Subject: [PATCH 21/40] Abstract away DbConnection creation for setup wizard --- src/Tgstation.Server.Host/Core/Application.cs | 1 + .../Core/DBConnectionFactory.cs | 37 +++++++++++++++++++ .../Core/IDBConnectionFactory.cs | 19 ++++++++++ src/Tgstation.Server.Host/Core/SetupWizard.cs | 26 ++++++++----- 4 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 src/Tgstation.Server.Host/Core/DBConnectionFactory.cs create mode 100644 src/Tgstation.Server.Host/Core/IDBConnectionFactory.cs diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index e0182463a2..63c5d3959d 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -101,6 +101,7 @@ namespace Tgstation.Server.Host.Core //setup stuff for setup wizard services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); //needed here for JWT configuration diff --git a/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs b/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs new file mode 100644 index 0000000000..0b92cad465 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs @@ -0,0 +1,37 @@ +using MySql.Data.MySqlClient; +using System; +using System.Data.Common; +using System.Data.SqlClient; +using System.Globalization; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Core +{ + /// + sealed class DBConnectionFactory : IDBConnectionFactory + { + /// + public DbConnection CreateConnection(string connectionString, DatabaseType databaseType) + { + if (connectionString == null) + throw new ArgumentNullException(nameof(connectionString)); + + switch (databaseType) + { + case DatabaseType.MariaDB: + case DatabaseType.MySql: + return new MySqlConnection + { + ConnectionString = connectionString + }; + case DatabaseType.SqlServer: + return new SqlConnection + { + ConnectionString = connectionString + }; + default: + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid database type ({0})!", databaseType)); + } + } + } +} diff --git a/src/Tgstation.Server.Host/Core/IDBConnectionFactory.cs b/src/Tgstation.Server.Host/Core/IDBConnectionFactory.cs new file mode 100644 index 0000000000..3f378b88b5 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/IDBConnectionFactory.cs @@ -0,0 +1,19 @@ +using System.Data.Common; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Core +{ + /// + /// For creating + /// + interface IDBConnectionFactory + { + /// + /// Create a + /// + /// The + /// The to create + /// A new + DbConnection CreateConnection(string connectionString, DatabaseType databaseType); + } +} diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index 182376fea4..ea9f11d3d8 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -40,6 +40,11 @@ namespace Tgstation.Server.Host.Core /// readonly IApplication application; + /// + /// The for the + /// + readonly IDBConnectionFactory dbConnectionFactory; + /// /// The for the /// @@ -56,14 +61,16 @@ namespace Tgstation.Server.Host.Core /// The value of /// 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) + public SetupWizard(IIOManager ioManager, IConsole console, IHostingEnvironment hostingEnvironment, IApplication application, IDBConnectionFactory dbConnectionFactory, 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.dbConnectionFactory = dbConnectionFactory ?? throw new ArgumentNullException(nameof(dbConnectionFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions)); } @@ -189,7 +196,13 @@ namespace Tgstation.Server.Host.Core await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); } + DbConnection testConnection; + void CreateTestConnection(string connectionString) + { + testConnection = dbConnectionFactory.CreateConnection(connectionString, databaseConfiguration.DatabaseType); + } + if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer) { var csb = new SqlConnectionStringBuilder @@ -204,11 +217,8 @@ namespace Tgstation.Server.Host.Core csb.UserID = username; csb.Password = password; } - testConnection = new SqlConnection - { - ConnectionString = csb.ConnectionString - }; + CreateTestConnection(csb.ConnectionString); csb.InitialCatalog = databaseName; databaseConfiguration.ConnectionString = csb.ConnectionString; } @@ -220,10 +230,8 @@ namespace Tgstation.Server.Host.Core UserID = username, Password = password }; - testConnection = new MySqlConnection - { - ConnectionString = csb.ConnectionString - }; + + CreateTestConnection(csb.ConnectionString); csb.Database = databaseName; databaseConfiguration.ConnectionString = csb.ConnectionString; } From 6b8291d9b572161392b747a0ea801843fa34435e Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 16:08:07 -0400 Subject: [PATCH 22/40] TestDBConnectionFactory --- .../Core/DBConnectionFactoryTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs diff --git a/tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs b/tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs new file mode 100644 index 0000000000..f514ae3f07 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs @@ -0,0 +1,29 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MySql.Data.MySqlClient; +using System; +using System.Data.SqlClient; +using Tgstation.Server.Host.Configuration; + +namespace Tgstation.Server.Host.Core.Tests +{ + [TestClass] + public sealed class DBConnectionFactoryTests + { + [TestMethod] + public void TestBadParameters() + { + var factory = new DBConnectionFactory(); + Assert.ThrowsException(() => factory.CreateConnection(null, default)); + Assert.ThrowsException(() => factory.CreateConnection(String.Empty, (DatabaseType)42)); + } + + [TestMethod] + public void TestWorks() + { + var factory = new DBConnectionFactory(); + Assert.IsInstanceOfType(factory.CreateConnection(String.Empty, DatabaseType.MariaDB), typeof(MySqlConnection)); + Assert.IsInstanceOfType(factory.CreateConnection(String.Empty, DatabaseType.MySql), typeof(MySqlConnection)); + Assert.IsInstanceOfType(factory.CreateConnection(String.Empty, DatabaseType.SqlServer), typeof(SqlConnection)); + } + } +} From 68ffe4baa6864f6b8a8314a536b8b31d37ee9d00 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 16:45:40 -0400 Subject: [PATCH 23/40] TestConsole --- .../Core/DBConnectionFactory.cs | 2 +- src/Tgstation.Server.Host/IO/Console.cs | 10 +++++-- ...oryTests.cs => TestDBConnectionFactory.cs} | 4 +-- .../IO/TestConsole.cs | 28 +++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) rename tests/Tgstation.Server.Host.Tests/Core/{DBConnectionFactoryTests.cs => TestDBConnectionFactory.cs} (84%) create mode 100644 tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs diff --git a/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs b/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs index 0b92cad465..8378d28d00 100644 --- a/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs +++ b/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs @@ -30,7 +30,7 @@ namespace Tgstation.Server.Host.Core ConnectionString = connectionString }; default: - throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid database type ({0})!", databaseType)); + throw new ArgumentOutOfRangeException(nameof(databaseType), databaseType, "Invalid DatabaseType!"); } } } diff --git a/src/Tgstation.Server.Host/IO/Console.cs b/src/Tgstation.Server.Host/IO/Console.cs index 5c262b9f7e..7b8fce74b4 100644 --- a/src/Tgstation.Server.Host/IO/Console.cs +++ b/src/Tgstation.Server.Host/IO/Console.cs @@ -12,7 +12,7 @@ namespace Tgstation.Server.Host.IO public bool Available => Environment.UserInteractive; /// - public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() => System.Console.ReadKey(), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() => System.Console.Read(), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); /// public Task ReadLineAsync(bool usePasswordChar, CancellationToken cancellationToken) => Task.Factory.StartNew(() => @@ -50,7 +50,13 @@ namespace Tgstation.Server.Host.IO /// public Task WriteAsync(string text, bool newLine, CancellationToken cancellationToken) => Task.Factory.StartNew(() => { - if (newLine) + if (text == null) + { + if (!newLine) + throw new InvalidOperationException("Cannot write null text without a new line!"); + System.Console.WriteLine(); + } + else if (newLine) System.Console.WriteLine(text); else System.Console.Write(text); diff --git a/tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs b/tests/Tgstation.Server.Host.Tests/Core/TestDBConnectionFactory.cs similarity index 84% rename from tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs rename to tests/Tgstation.Server.Host.Tests/Core/TestDBConnectionFactory.cs index f514ae3f07..39abca29bb 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/DBConnectionFactoryTests.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestDBConnectionFactory.cs @@ -7,14 +7,14 @@ using Tgstation.Server.Host.Configuration; namespace Tgstation.Server.Host.Core.Tests { [TestClass] - public sealed class DBConnectionFactoryTests + public sealed class TestDBConnectionFactory { [TestMethod] public void TestBadParameters() { var factory = new DBConnectionFactory(); Assert.ThrowsException(() => factory.CreateConnection(null, default)); - Assert.ThrowsException(() => factory.CreateConnection(String.Empty, (DatabaseType)42)); + Assert.ThrowsException(() => factory.CreateConnection(String.Empty, (DatabaseType)42)); } [TestMethod] diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs new file mode 100644 index 0000000000..d1a1ece5c2 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs @@ -0,0 +1,28 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.IO.Tests +{ + [TestClass] + public sealed class TestConsole + { + [TestMethod] + public async Task TestWriteLine() + { + var console = new Console(); + await Assert.ThrowsExceptionAsync(() => console.WriteAsync(null, false, default)).ConfigureAwait(false); + await console.WriteAsync(null, true, default).ConfigureAwait(false); + await console.WriteAsync(String.Empty, false, default).ConfigureAwait(true); + } + + [TestMethod] + public void TestUserInteractive() + { + var console = new Console(); + Assert.AreEqual(Environment.UserInteractive, console.Available); + } + } +} From dd00893796980391d83429457f5e9fdc8c05f9ff Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 17:20:03 -0400 Subject: [PATCH 24/40] Remove redundant code --- src/Tgstation.Server.Host/Core/SetupWizard.cs | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index ea9f11d3d8..02ebbb7b55 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -157,25 +157,7 @@ namespace Tgstation.Server.Host.Core 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); + var dbExists = await PromptYesNo("Does this database already exist? (y/n): ", cancellationToken).ConfigureAwait(false); bool? useWinAuth; if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) From 1ad7d8bbe519e7c4ef5ace437005f94c0ca31ffd Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Thu, 4 Oct 2018 17:43:44 -0400 Subject: [PATCH 25/40] WIP tests for setup wizard --- .../Core/TestSetupWizard.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs new file mode 100644 index 0000000000..2db14803d4 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs @@ -0,0 +1,183 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using System; +using System.Collections.Generic; +using System.Data.Common; +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.Tests +{ + [TestClass] + public sealed class TestSetupWizard + { + [TestMethod] + public void TestConstructionThrows() + { + Assert.ThrowsException(() => new SetupWizard(null, null, null, null, null, null, null)); + var mockIOManager = new Mock(); + Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, null, null, null, null, null, null)); + var mockConsole = new Mock(); + Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, null, null, null, null, null)); + var mockHostingEnvironment = new Mock(); + Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, null, null, null, null)); + var mockApplication = new Mock(); + Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, null, null, null)); + var mockDBConnectionFactory = new Mock(); + Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, mockDBConnectionFactory.Object, null, null)); + var mockLogger = new Mock>(); + Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, mockDBConnectionFactory.Object, mockLogger.Object, null)); + } + + //TODO + [TestMethod] + public async Task WIPTestWithUserStupiditiy() + { + Assert.Inconclusive(); + + var mockIOManager = new Mock(); + var mockConsole = new Mock(); + var mockHostingEnvironment = new Mock(); + var mockApplication = new Mock(); + var mockDBConnectionFactory = new Mock(); + var mockLogger = new Mock>(); + var mockGeneralConfigurationOptions = new Mock>(); + + var testGeneralConfig = new GeneralConfiguration + { + SetupWizardMode = SetupWizardMode.Never + }; + mockGeneralConfigurationOptions.SetupGet(x => x.Value).Returns(testGeneralConfig).Verifiable(); + + var wizard = new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, mockDBConnectionFactory.Object, mockLogger.Object, mockGeneralConfigurationOptions.Object); + + Assert.IsFalse(await wizard.CheckRunWizard(default).ConfigureAwait(false)); + + testGeneralConfig.SetupWizardMode = SetupWizardMode.Force; + await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); + + testGeneralConfig.SetupWizardMode = SetupWizardMode.Only; + mockConsole.SetupGet(x => x.Available).Returns(true).Verifiable(); + Assert.IsFalse(await wizard.CheckRunWizard(default).ConfigureAwait(false)); + + mockIOManager.Setup(x => x.FileExists(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(true)).Verifiable(); + mockIOManager.Setup(x => x.ReadAllBytes(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(Encoding.UTF8.GetBytes("cucked"))).Verifiable(); + mockIOManager.Setup(x => x.WriteAllBytes(It.IsNotNull(), It.IsNotNull(), It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + + var mockGoodDbConnection = new Mock(); + mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + + var mockBadDbConnection = new Mock(); + mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Throws(new Exception()).Verifiable(); + + void AddVersionReturn(Mock mock) => mock.Setup(x => x.ExecuteScalarAsync(It.IsAny())).Returns(Task.FromResult("1.2.3")).Verifiable(); + var mockSuccessCommand = new Mock(); + mockSuccessCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); + AddVersionReturn(mockSuccessCommand); + var mockFailCommand = new Mock(); + mockFailCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny())).Throws(new Exception()).Verifiable(); + AddVersionReturn(mockFailCommand); + var secondTime = false; + var mockUglyDbConnection = new Mock(); + mockUglyDbConnection.Setup(x => x.CreateCommand()).Returns(() => + { + if (!secondTime) + { + secondTime = true; + return mockSuccessCommand.Object; + } + else + return mockFailCommand.Object; + }).Verifiable(); + + mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny(), DatabaseType.SqlServer)).Returns(mockBadDbConnection.Object).Verifiable(); + mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny(), DatabaseType.MariaDB)).Returns(mockGoodDbConnection.Object).Verifiable(); + mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny(), DatabaseType.MySql)).Returns(mockUglyDbConnection.Object).Verifiable(); + + var finalInputSequence = new List() + { + //first run, just say no to the force prompt after testing it + "fake", + "n", + //second run say yes to the force prompt + "y", + //first normal run + "bad port number", + "0", + "666", + "FakeDBType", + nameof(DatabaseType.SqlServer), + "this isn't validated", + "nor is this", + "no", + }; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + finalInputSequence.Add("yes"); + else + finalInputSequence.AddRange(new List + { + "username", + "password" + }); + finalInputSequence.AddRange(new List + { + //sql server will always fail so reconfigure with maria + nameof(DatabaseType.MariaDB), + "bleh", + "blah", + "user", + "pass", + //general config + "four", + "-12", + "16", + "eight", + "-27", + "5000", + "fake token", + //saved, now for second run + //this time use defaults amap + + //TODO + }); + + var inputPos = 0; + + mockConsole.Setup(x => x.PressAnyKeyAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + mockConsole.Setup(x => x.ReadLineAsync(It.IsAny(), It.IsAny())).Returns(() => + { + if (inputPos == finalInputSequence.Count) + Assert.Fail("Exhausted input sequence!"); + return Task.FromResult(finalInputSequence[inputPos++]); + }).Verifiable(); + mockConsole.Setup(x => x.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + + //first real run + Assert.IsTrue(await wizard.CheckRunWizard(default).ConfigureAwait(false)); + + //second run + mockIOManager.Setup(x => x.ReadAllBytes(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(Encoding.UTF8.GetBytes(String.Empty))).Verifiable(); + Assert.IsTrue(await wizard.CheckRunWizard(default).ConfigureAwait(false)); + + //third run + testGeneralConfig.SetupWizardMode = SetupWizardMode.Autodetect; + mockIOManager.Setup(x => x.WriteAllBytes(It.IsNotNull(), It.IsNotNull(), It.IsAny())).Throws(new Exception()).Verifiable(); + await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); + + mockFailCommand.VerifyAll(); + mockSuccessCommand.VerifyAll(); + mockIOManager.VerifyAll(); + mockGeneralConfigurationOptions.VerifyAll(); + mockConsole.VerifyAll(); + mockGoodDbConnection.VerifyAll(); + mockDBConnectionFactory.VerifyAll(); + } + } +} From fbc1a5459f6b9658fe160f97c0a1cc478393d9fd Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Thu, 4 Oct 2018 23:47:11 -0400 Subject: [PATCH 26/40] Fix build --- src/Tgstation.Server.Host/Core/Application.cs | 1 - src/Tgstation.Server.Host/Core/SetupWizard.cs | 1 + tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 63c5d3959d..ef13f01292 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -80,7 +80,6 @@ namespace Tgstation.Server.Host.Core /// Configure dependency injected services /// /// The to configure - /// The representing the lifetime of the public void ConfigureServices(IServiceCollection services) { if (services == null) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index 02ebbb7b55..832be49198 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -59,6 +59,7 @@ namespace Tgstation.Server.Host.Core /// Construct a /// /// The value of + /// The value of /// The value of /// The value of /// The value of diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs index 2db14803d4..dd0c5b30eb 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs @@ -119,6 +119,7 @@ namespace Tgstation.Server.Host.Core.Tests "no", }; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + //test winauth finalInputSequence.Add("yes"); else finalInputSequence.AddRange(new List From 71dfb81d43de00e6ba1605c7d28d61cf736edee7 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 10:58:52 -0400 Subject: [PATCH 27/40] Finish setup wizard tests, fix a few bugs --- src/Tgstation.Server.Host/Core/SetupWizard.cs | 25 +++--- .../Core/TestSetupWizard.cs | 80 +++++++++++++------ 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index 02ebbb7b55..6c54f7ebbd 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -155,21 +155,30 @@ namespace Tgstation.Server.Host.Core 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); + string databaseName; + + do + { + databaseName = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (!String.IsNullOrWhiteSpace(databaseName)) + break; + await console.WriteAsync("Invalid database name!", true, cancellationToken).ConfigureAwait(false); + } + while (true); var dbExists = await PromptYesNo("Does this database already exist? (y/n): ", cancellationToken).ConfigureAwait(false); - bool? useWinAuth; + 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; + useWinAuth = false; await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); string username = null; string password = null; - if (useWinAuth != true) + if (!useWinAuth) { await console.WriteAsync("Enter username: ", false, cancellationToken).ConfigureAwait(false); username = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); @@ -192,7 +201,7 @@ namespace Tgstation.Server.Host.Core ApplicationName = application.VersionPrefix, DataSource = serverAddress ?? "(local)" }; - if (useWinAuth.Value) + if (useWinAuth) csb.IntegratedSecurity = true; else { @@ -421,14 +430,12 @@ namespace Tgstation.Server.Host.Core } var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName); - var existenceTask = ioManager.FileExists(userConfigFileName, default); - var exists = existenceTask.GetAwaiter().GetResult(); + var exists = await ioManager.FileExists(userConfigFileName, cancellationToken).ConfigureAwait(false); bool shouldRunBasedOnAutodetect; if (exists) { - var readTask = ioManager.ReadAllBytes(userConfigFileName, default); - var bytes = readTask.GetAwaiter().GetResult(); + var bytes = await ioManager.ReadAllBytes(userConfigFileName, cancellationToken).ConfigureAwait(false); var contents = Encoding.UTF8.GetString(bytes); var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents); logger.LogTrace("Configuration json detected. Empty: {0}", existingConfigIsEmpty); diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs index 2db14803d4..61d9f29a53 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; +using Moq.Protected; using System; using System.Collections.Generic; using System.Data.Common; @@ -35,13 +36,10 @@ namespace Tgstation.Server.Host.Core.Tests var mockLogger = new Mock>(); Assert.ThrowsException(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, mockDBConnectionFactory.Object, mockLogger.Object, null)); } - - //TODO + [TestMethod] - public async Task WIPTestWithUserStupiditiy() + public async Task TestWithUserStupiditiy() { - Assert.Inconclusive(); - var mockIOManager = new Mock(); var mockConsole = new Mock(); var mockHostingEnvironment = new Mock(); @@ -64,38 +62,39 @@ namespace Tgstation.Server.Host.Core.Tests await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); testGeneralConfig.SetupWizardMode = SetupWizardMode.Only; - mockConsole.SetupGet(x => x.Available).Returns(true).Verifiable(); - Assert.IsFalse(await wizard.CheckRunWizard(default).ConfigureAwait(false)); + await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); + mockConsole.SetupGet(x => x.Available).Returns(true).Verifiable(); mockIOManager.Setup(x => x.FileExists(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(true)).Verifiable(); mockIOManager.Setup(x => x.ReadAllBytes(It.IsNotNull(), It.IsAny())).Returns(Task.FromResult(Encoding.UTF8.GetBytes("cucked"))).Verifiable(); mockIOManager.Setup(x => x.WriteAllBytes(It.IsNotNull(), It.IsNotNull(), It.IsAny())).Returns(Task.CompletedTask).Verifiable(); - var mockGoodDbConnection = new Mock(); - mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); - - var mockBadDbConnection = new Mock(); - mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Throws(new Exception()).Verifiable(); - - void AddVersionReturn(Mock mock) => mock.Setup(x => x.ExecuteScalarAsync(It.IsAny())).Returns(Task.FromResult("1.2.3")).Verifiable(); var mockSuccessCommand = new Mock(); mockSuccessCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); - AddVersionReturn(mockSuccessCommand); + mockSuccessCommand.Setup(x => x.ExecuteScalarAsync(It.IsAny())).Returns(Task.FromResult("1.2.3")).Verifiable(); var mockFailCommand = new Mock(); mockFailCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny())).Throws(new Exception()).Verifiable(); - AddVersionReturn(mockFailCommand); - var secondTime = false; + + void SetDbCommandCreator(Mock mock, Func creator) => mock.Protected().Setup("CreateDbCommand").Returns(creator).Verifiable(); + + var mockGoodDbConnection = new Mock(); + mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + SetDbCommandCreator(mockGoodDbConnection, () => mockSuccessCommand.Object); + + var mockBadDbConnection = new Mock(); + mockBadDbConnection.Setup(x => x.OpenAsync(It.IsAny())).Throws(new Exception()).Verifiable(); + var invokeTimes = 0; var mockUglyDbConnection = new Mock(); - mockUglyDbConnection.Setup(x => x.CreateCommand()).Returns(() => + SetDbCommandCreator(mockUglyDbConnection, () => { - if (!secondTime) + if (invokeTimes < 2) { - secondTime = true; + ++invokeTimes; return mockSuccessCommand.Object; } else return mockFailCommand.Object; - }).Verifiable(); + }); mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny(), DatabaseType.SqlServer)).Returns(mockBadDbConnection.Object).Verifiable(); mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny(), DatabaseType.MariaDB)).Returns(mockGoodDbConnection.Object).Verifiable(); @@ -132,6 +131,7 @@ namespace Tgstation.Server.Host.Core.Tests nameof(DatabaseType.MariaDB), "bleh", "blah", + "NO", "user", "pass", //general config @@ -144,21 +144,47 @@ namespace Tgstation.Server.Host.Core.Tests "fake token", //saved, now for second run //this time use defaults amap - - //TODO + String.Empty, + //test MySQL errors + nameof(DatabaseType.MySql), + String.Empty, + String.Empty, + "DbName", + "n", + "user", + "pass", + //general config + String.Empty, + String.Empty, + String.Empty, + //third run, we already hit all the code coverage so just get through it + String.Empty, + nameof(DatabaseType.MariaDB), + String.Empty, + "dbname", + "y", + "user", + "pass", + String.Empty, + String.Empty, + String.Empty }); var inputPos = 0; + mockApplication.SetupGet(x => x.VersionPrefix).Returns("sumfuk").Verifiable(); + mockConsole.Setup(x => x.PressAnyKeyAsync(It.IsAny())).Returns(Task.CompletedTask).Verifiable(); mockConsole.Setup(x => x.ReadLineAsync(It.IsAny(), It.IsAny())).Returns(() => { if (inputPos == finalInputSequence.Count) Assert.Fail("Exhausted input sequence!"); - return Task.FromResult(finalInputSequence[inputPos++]); + var res = finalInputSequence[inputPos++]; + return Task.FromResult(res); }).Verifiable(); mockConsole.Setup(x => x.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.CompletedTask).Verifiable(); + Assert.IsFalse(await wizard.CheckRunWizard(default).ConfigureAwait(false)); //first real run Assert.IsTrue(await wizard.CheckRunWizard(default).ConfigureAwait(false)); @@ -169,15 +195,19 @@ namespace Tgstation.Server.Host.Core.Tests //third run testGeneralConfig.SetupWizardMode = SetupWizardMode.Autodetect; mockIOManager.Setup(x => x.WriteAllBytes(It.IsNotNull(), It.IsNotNull(), It.IsAny())).Throws(new Exception()).Verifiable(); - await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); + await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); + Assert.AreEqual(finalInputSequence.Count, inputPos); mockFailCommand.VerifyAll(); mockSuccessCommand.VerifyAll(); mockIOManager.VerifyAll(); mockGeneralConfigurationOptions.VerifyAll(); mockConsole.VerifyAll(); mockGoodDbConnection.VerifyAll(); + mockBadDbConnection.VerifyAll(); + mockUglyDbConnection.VerifyAll(); mockDBConnectionFactory.VerifyAll(); + mockApplication.VerifyAll(); } } } From 2b2239f9ce689cbe194290257eb2635020e1def5 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 11:01:17 -0400 Subject: [PATCH 28/40] Fill out doc comments --- src/Tgstation.Server.Host/Core/SetupWizard.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index 86b3d690b1..74556b3663 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -338,6 +338,15 @@ namespace Tgstation.Server.Host.Core return generalConfiguration; } + /// + /// Saves a given set to + /// + /// The file to save the to + /// The hosting port to save + /// The to save + /// The to save + /// The for the operation + /// A representing the running operation 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); @@ -391,7 +400,7 @@ namespace Tgstation.Server.Host.Core /// /// The path to the settings json to build /// The for the operation - /// + /// A representing the running operation async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken) { //welcome message From 33ea7cbae5c0d5585eb1bebe3ba1541c76346154 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 11:23:44 -0400 Subject: [PATCH 29/40] Host watchdog fixes Fix service configuration parameters and admin launch conditions Make service Program methods async Fix watchdog configuration launch parameter Fix watchdog debug setup --- src/Tgstation.Server.Host.Service/Program.cs | 39 +++++++++++-------- .../Watchdog.cs | 4 +- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/Tgstation.Server.Host.Service/Program.cs b/src/Tgstation.Server.Host.Service/Program.cs index 1b38650650..08cacea599 100644 --- a/src/Tgstation.Server.Host.Service/Program.cs +++ b/src/Tgstation.Server.Host.Service/Program.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Reflection; using System.Security.Principal; using System.ServiceProcess; +using System.Threading.Tasks; using System.Windows.Forms; using Tgstation.Server.Host.Watchdog; @@ -65,37 +66,41 @@ namespace Tgstation.Server.Host.Service /// /// Command line handler, always runs /// - public void OnExecute() + public async Task OnExecuteAsync() { - if (Environment.UserInteractive && !IsAdministrator()) + if (Environment.UserInteractive) { - if (!Install && !Uninstall) + if (!Install && !Uninstall && !Configure) { var result = MessageBox.Show("You are running the TGS windows service executable directly. It should only be run by the service control manager. Would you like to install and configure the service in this location?", "TGS Service", MessageBoxButtons.YesNo); if (result != DialogResult.Yes) return; Install = true; + Configure = true; } - //try to restart as admin - //its windows, first arg is .exe name guaranteed - var exe = Environment.GetCommandLineArgs().First(); - var startInfo = new ProcessStartInfo + if (!IsAdministrator()) { - UseShellExecute = true, - Verb = "runas", - Arguments = Install ? "-i -c" : "-u", - FileName = exe, - WorkingDirectory = Environment.CurrentDirectory, - }; - using (Process.Start(startInfo)) - return; + //try to restart as admin + //its windows, first arg is .exe name guaranteed + var exe = Environment.GetCommandLineArgs().First(); + var startInfo = new ProcessStartInfo + { + UseShellExecute = true, + Verb = "runas", + Arguments = String.Format(CultureInfo.InvariantCulture, "{0} {1}", Install ? "-i" : Uninstall ? "-u" : String.Empty, Configure ? "-c" : String.Empty), + FileName = exe, + WorkingDirectory = Environment.CurrentDirectory, + }; + using (Process.Start(startInfo)) + return; + } } using (var loggerFactory = new LoggerFactory()) { if (Configure) - watchdogFactory.CreateWatchdog(loggerFactory).RunAsync(true, Array.Empty(), default); + await watchdogFactory.CreateWatchdog(loggerFactory).RunAsync(true, Array.Empty(), default).ConfigureAwait(false); if (Install) { @@ -136,6 +141,6 @@ namespace Tgstation.Server.Host.Service /// Entrypoint for the application /// [STAThread] - static int Main(string[] args) => CommandLineApplication.Execute(args); + static Task Main(string[] args) => CommandLineApplication.ExecuteAsync(args); } } diff --git a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs index 3122ed5fce..9dce48ac35 100644 --- a/src/Tgstation.Server.Host.Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host.Watchdog/Watchdog.cs @@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Watchdog Directory.Delete(assemblyStoragePath, true); Directory.CreateDirectory(defaultAssemblyPath); - var sourcePath = "../../../../Tgstation.Server.Host/bin/Debug/netcoreapp2.1"; + var sourcePath = "../../../Tgstation.Server.Host/bin/Debug/netcoreapp2.1"; foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories)) Directory.CreateDirectory(dirPath.Replace(sourcePath, defaultAssemblyPath)); @@ -131,7 +131,7 @@ namespace Tgstation.Server.Host.Watchdog if (runConfigure) { logger.LogInformation("Running configuration check and wizard if necessary..."); - arguments.Add("General:ConfigCheckOnly=true"); + arguments.Add("General:SetupWizardMode=Only"); } arguments.AddRange(args); From 059a5d844745250fa5cb05097119fedfc51dc1c3 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 11:24:17 -0400 Subject: [PATCH 30/40] Remove and suppress the STA BS --- .../GlobalSuppressions.cs | Bin 2326 -> 2588 bytes src/Tgstation.Server.Host.Service/Program.cs | 1 - 2 files changed, 1 deletion(-) diff --git a/src/Tgstation.Server.Host.Service/GlobalSuppressions.cs b/src/Tgstation.Server.Host.Service/GlobalSuppressions.cs index 44ed7e1b55769fda5ee15d9e3cebecaa2a6953d1..47f7930b2a164fa8d872b6d46cf75d67298e52a5 100644 GIT binary patch delta 144 zcmbOxG)H8E7wcpu#-936hGK?9hIEEh1|2#{3-v>_3w YUx`7JA(nxcfr~*O2o*N_vmR##0Qx~2I{*Lx delta 12 TcmbOuGEHcM7whH#wlmBC9L5AN diff --git a/src/Tgstation.Server.Host.Service/Program.cs b/src/Tgstation.Server.Host.Service/Program.cs index 08cacea599..0c758c4de7 100644 --- a/src/Tgstation.Server.Host.Service/Program.cs +++ b/src/Tgstation.Server.Host.Service/Program.cs @@ -140,7 +140,6 @@ namespace Tgstation.Server.Host.Service /// /// Entrypoint for the application /// - [STAThread] static Task Main(string[] args) => CommandLineApplication.ExecuteAsync(args); } } From 21920f3dcc969f8dcebcb1b48c9d0d7de0803be1 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 11:26:16 -0400 Subject: [PATCH 31/40] Fix some console write conflicts --- src/Tgstation.Server.Host/Core/SetupWizard.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index 74556b3663..76d727f07e 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -471,6 +471,9 @@ namespace Tgstation.Server.Host.Core return false; } + //flush the logs to prevent console conflicts + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(false); + await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false); return true; } From ce00995b8bf34176345973ab1ba4c2f13c38ce81 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 12:08:58 -0400 Subject: [PATCH 32/40] Increase console sanity --- src/Tgstation.Server.Host/IO/Console.cs | 14 +++++++++++++- .../Tgstation.Server.Host.Tests/IO/TestConsole.cs | 11 +++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Tgstation.Server.Host/IO/Console.cs b/src/Tgstation.Server.Host/IO/Console.cs index 7b8fce74b4..35d3b1e135 100644 --- a/src/Tgstation.Server.Host/IO/Console.cs +++ b/src/Tgstation.Server.Host/IO/Console.cs @@ -11,13 +11,24 @@ namespace Tgstation.Server.Host.IO /// public bool Available => Environment.UserInteractive; + void CheckAvailable() + { + if (!Available) + throw new InvalidOperationException("Console unavailable"); + } + /// - public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() => System.Console.Read(), cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + CheckAvailable(); + System.Console.Read(); + }, 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 + CheckAvailable(); if (!usePasswordChar) return System.Console.ReadLine(); @@ -50,6 +61,7 @@ namespace Tgstation.Server.Host.IO /// public Task WriteAsync(string text, bool newLine, CancellationToken cancellationToken) => Task.Factory.StartNew(() => { + CheckAvailable(); if (text == null) { if (!newLine) diff --git a/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs index d1a1ece5c2..59335e2425 100644 --- a/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs +++ b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs @@ -14,8 +14,15 @@ namespace Tgstation.Server.Host.IO.Tests { var console = new Console(); await Assert.ThrowsExceptionAsync(() => console.WriteAsync(null, false, default)).ConfigureAwait(false); - await console.WriteAsync(null, true, default).ConfigureAwait(false); - await console.WriteAsync(String.Empty, false, default).ConfigureAwait(true); + try + { + await console.WriteAsync(null, true, default).ConfigureAwait(false); + await console.WriteAsync(String.Empty, false, default).ConfigureAwait(true); + } + catch(InvalidOperationException) + { + Assert.IsFalse(console.Available); + } } [TestMethod] From 0240c100bd569223fc3cda86495f50b13a8f66c7 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 12:09:15 -0400 Subject: [PATCH 33/40] Never lie in documentation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bbe606c69b..a178404c97 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ The first time you run TGS4 you should be prompted with a configuration wizard w ![](https://user-images.githubusercontent.com/8171642/46436355-99ee0e00-c726-11e8-82fa-6626b2503a6c.png) -This wizard will run whenever the server is launched without detecting the config json. Follow the instructions below to perform this process manually. +This wizard will, generally, run whenever the server is launched without detecting the config json. Follow the instructions below to perform this process manually. #### Manual Configuration From 1a50a65a2678ad8ea0a2fc9aa2d3c00a6c3c4fe4 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 12:09:46 -0400 Subject: [PATCH 34/40] Add default log levels for FileLoggingConfiguration --- .../Configuration/FileLoggingConfiguration.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 2b731639cc..7beb0a4477 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -14,6 +14,16 @@ namespace Tgstation.Server.Host.Configuration /// public const string Section = "FileLogging"; + /// + /// Default value for + /// + const LogLevel DefaultLogLevel = LogLevel.Debug; + + /// + /// Default value for + /// + const LogLevel DefaultMicrosoftLogLevel = LogLevel.Warning; + /// /// Where log files are stored /// @@ -28,13 +38,13 @@ namespace Tgstation.Server.Host.Configuration /// The ified minimum to display in logs /// [JsonConverter(typeof(StringEnumConverter))] - public LogLevel LogLevel { get; set; } + public LogLevel LogLevel { get; set; } = DefaultLogLevel; /// /// The ified minimum to display in logs for Microsoft library sources /// [JsonConverter(typeof(StringEnumConverter))] - public LogLevel MicrosoftLogLevel { get; set; } + public LogLevel MicrosoftLogLevel { get; set; } = DefaultMicrosoftLogLevel; } } From f17e2b131e5eec6b780193cf0d84290845b20ef0 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 12:30:08 -0400 Subject: [PATCH 35/40] SetupWizard buffs Add notes about SQL permissions as a Windows service Add file logging configuration Properly rethrow OperationCanceledExceptions Stops generalConfiguration var name clash --- src/Tgstation.Server.Host/Core/SetupWizard.cs | 140 ++++++++++++++++-- 1 file changed, 124 insertions(+), 16 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index 76d727f07e..e5f682b431 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -185,9 +185,14 @@ namespace Tgstation.Server.Host.Core 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); } - + else + { + await console.WriteAsync("IMPORTANT: If using the service runner, ensure this computer's LocalSystem account has CREATE DATABASE permissions on the target server!", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("The account it uses in MSSQL is usually \"NT AUTHORITY\\SYSTEM\" and the role it needs is usually \"dbcreator\".", true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("We'll run a sanity test here, but it won't be indicative of the service's permissions if that is the case", true, cancellationToken).ConfigureAwait(false); + } + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); DbConnection testConnection; void CreateTestConnection(string connectionString) @@ -266,6 +271,10 @@ namespace Tgstation.Server.Host.Core { await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) + { + throw; + } catch (Exception e) { await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); @@ -280,6 +289,10 @@ namespace Tgstation.Server.Host.Core return databaseConfiguration; } + catch (OperationCanceledException) + { + throw; + } catch (Exception e) { await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); @@ -296,17 +309,20 @@ namespace Tgstation.Server.Host.Core /// A resulting in the new async Task ConfigureGeneral(CancellationToken cancellationToken) { - var generalConfiguration = new GeneralConfiguration(); + var newGeneralConfiguration = new GeneralConfiguration + { + SetupWizardMode = SetupWizardMode.Never + }; 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); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", newGeneralConfiguration.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; + newGeneralConfiguration.MinimumPasswordLength = passwordLength; break; } await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false); @@ -316,13 +332,13 @@ namespace Tgstation.Server.Host.Core 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); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Timeout for sending and receiving BYOND topics (ms, 0 for infinite, leave blank for default of {0}): ", newGeneralConfiguration.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; + newGeneralConfiguration.ByondTopicTimeout = topicTimeout; break; } await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false); @@ -332,10 +348,93 @@ namespace Tgstation.Server.Host.Core 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; + newGeneralConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(newGeneralConfiguration.GitHubAccessToken)) + newGeneralConfiguration.GitHubAccessToken = null; + return newGeneralConfiguration; + } + + /// + /// Prompts the user to create a + /// + /// The for the operation + /// A resulting in the new + async Task ConfigureLogging(CancellationToken cancellationToken) + { + var fileLoggingConfiguration = new FileLoggingConfiguration(); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + fileLoggingConfiguration.Disable = !await PromptYesNo("Enable file logging? (y/n): ", cancellationToken).ConfigureAwait(false); + + if (!fileLoggingConfiguration.Disable) + { + do + { + await console.WriteAsync("Log file directory path (leave blank for default): ", false, cancellationToken).ConfigureAwait(false); + fileLoggingConfiguration.Directory = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(fileLoggingConfiguration.Directory)) + { + fileLoggingConfiguration.Directory = null; + break; + } + else + { + //test a write of it + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Testing directory access...", true, cancellationToken).ConfigureAwait(false); + try + { + await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken).ConfigureAwait(false); + var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid())); + await ioManager.WriteAllBytes(testFile, Array.Empty(), cancellationToken).ConfigureAwait(false); + try + { + await ioManager.DeleteFile(testFile, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + } + break; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken).ConfigureAwait(false); + } + } + } while (true); + + async Task PromptLogLevel(string question) + { + do + { + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(question, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Enter one of {0}/{1}/{2}/{3}/{4}/{5} (leave blank for default): ",nameof(LogLevel.Trace), nameof(LogLevel.Debug), nameof(LogLevel.Information), nameof(LogLevel.Warning), nameof(LogLevel.Error), nameof(LogLevel.Critical)), false, cancellationToken).ConfigureAwait(false); + var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false); + if (String.IsNullOrWhiteSpace(responseString)) + return null; + if (Enum.TryParse(responseString, out var logLevel) && logLevel != LogLevel.None) + return logLevel; + await console.WriteAsync("Invalid log level!", true, cancellationToken).ConfigureAwait(false); + } while (true); + } + + fileLoggingConfiguration.LogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for normal logs (default {0}).", fileLoggingConfiguration.LogLevel)).ConfigureAwait(false) ?? fileLoggingConfiguration.LogLevel; + fileLoggingConfiguration.MicrosoftLogLevel = await PromptLogLevel(String.Format(CultureInfo.InvariantCulture, "Enter the level limit for Microsoft logs (VERY verbose, default {0}).", fileLoggingConfiguration.MicrosoftLogLevel)).ConfigureAwait(false) ?? fileLoggingConfiguration.MicrosoftLogLevel; + } + return fileLoggingConfiguration; } /// @@ -344,17 +443,19 @@ namespace Tgstation.Server.Host.Core /// The file to save the to /// The hosting port to save /// The to save - /// The to save + /// The to save + /// The to save /// The for the operation /// A representing the running operation - async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration generalConfiguration, CancellationToken cancellationToken) + async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration newGeneralConfiguration, FileLoggingConfiguration fileLoggingConfiguration, 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 } + { GeneralConfiguration.Section, newGeneralConfiguration }, + { FileLoggingConfiguration.Section, fileLoggingConfiguration } }; if (hostingPort.HasValue) @@ -376,6 +477,10 @@ namespace Tgstation.Server.Host.Core { await ioManager.WriteAllBytes(userConfigFileName, configBytes, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) + { + throw; + } catch (Exception e) { await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); @@ -404,6 +509,7 @@ namespace Tgstation.Server.Host.Core async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken) { //welcome message + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); 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); @@ -411,11 +517,13 @@ namespace Tgstation.Server.Host.Core var databaseConfiguration = await ConfigureDatabase(cancellationToken).ConfigureAwait(false); - var generalConfiguration = await ConfigureGeneral(cancellationToken).ConfigureAwait(false); + var newGeneralConfiguration = await ConfigureGeneral(cancellationToken).ConfigureAwait(false); + + var fileLoggingConfiguration = await ConfigureLogging(cancellationToken).ConfigureAwait(false); await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); - await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, generalConfiguration, cancellationToken).ConfigureAwait(false); + await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, newGeneralConfiguration, fileLoggingConfiguration, cancellationToken).ConfigureAwait(false); } /// From be040f16413c388e00a3836147877a8bf6166c9b Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 12:41:02 -0400 Subject: [PATCH 36/40] Stop the service when the host watchdog exits --- .../ServerService.cs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Tgstation.Server.Host.Service/ServerService.cs b/src/Tgstation.Server.Host.Service/ServerService.cs index 1d34049e31..0758c021a3 100644 --- a/src/Tgstation.Server.Host.Service/ServerService.cs +++ b/src/Tgstation.Server.Host.Service/ServerService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.EventLog.Internal; using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.ServiceProcess; using System.Threading; using System.Threading.Tasks; @@ -65,6 +66,31 @@ namespace Tgstation.Server.Host.Service /// public void WriteEntry(string message, EventLogEntryType type, int eventID, short category) => EventLog.WriteEntry(message, type, eventID, category); + /// + /// Executes the , stopping the service if it exits + /// + /// The arguments for the + /// The for the operation + /// A representing the running operation + async Task RunWatchdog(string[] args, CancellationToken cancellationToken) + { + await watchdog.RunAsync(false, args, cancellationTokenSource.Token).ConfigureAwait(false); + + void StopServiceAsync() + { + try + { + Task.Run(Stop, cancellationToken); + } + catch (OperationCanceledException) { } + catch (Exception e) + { + EventLog.WriteEntry(String.Format(CultureInfo.InvariantCulture, "Error stopping service! Exception: {0}", e)); + } + } + StopServiceAsync(); + } + /// [SuppressMessage("Microsoft.Usage", "CA2213:DisposableFieldsShouldBeDisposed", MessageId = "cancellationTokenSource")] protected override void Dispose(bool disposing) @@ -78,7 +104,7 @@ namespace Tgstation.Server.Host.Service { cancellationTokenSource?.Dispose(); cancellationTokenSource = new CancellationTokenSource(); - watchdogTask = watchdog.RunAsync(false, args, cancellationTokenSource.Token); + watchdogTask = RunWatchdog(args, cancellationTokenSource.Token); } /// From f88b769647eeef2570c4c932ec9a9b53e772f250 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 13:01:12 -0400 Subject: [PATCH 37/40] Fix setup wizard tests --- src/Tgstation.Server.Host/Core/SetupWizard.cs | 43 +++++++++---------- .../Core/TestSetupWizard.cs | 27 +++++++++++- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/Tgstation.Server.Host/Core/SetupWizard.cs b/src/Tgstation.Server.Host/Core/SetupWizard.cs index e5f682b431..1d3cb0bd65 100644 --- a/src/Tgstation.Server.Host/Core/SetupWizard.cs +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -376,31 +376,17 @@ namespace Tgstation.Server.Host.Core fileLoggingConfiguration.Directory = null; break; } - else + //test a write of it + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Testing directory access...", true, cancellationToken).ConfigureAwait(false); + try { - //test a write of it - await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); - await console.WriteAsync("Testing directory access...", true, cancellationToken).ConfigureAwait(false); + await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken).ConfigureAwait(false); + var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid())); + await ioManager.WriteAllBytes(testFile, Array.Empty(), cancellationToken).ConfigureAwait(false); try { - await ioManager.CreateDirectory(fileLoggingConfiguration.Directory, cancellationToken).ConfigureAwait(false); - var testFile = ioManager.ConcatPath(fileLoggingConfiguration.Directory, String.Format(CultureInfo.InvariantCulture, "WizardAccesTest.{0}.deleteme", Guid.NewGuid())); - await ioManager.WriteAllBytes(testFile, Array.Empty(), cancellationToken).ConfigureAwait(false); - try - { - await ioManager.DeleteFile(testFile, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken).ConfigureAwait(false); - await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); - await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); - } - break; + await ioManager.DeleteFile(testFile, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -408,10 +394,21 @@ namespace Tgstation.Server.Host.Core } catch (Exception e) { + await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Error deleting test log file: {0}", testFile), true, cancellationToken).ConfigureAwait(false); await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); - await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken).ConfigureAwait(false); } + break; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception e) + { + await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + await console.WriteAsync("Please verify the path is valid and you have access to it!", true, cancellationToken).ConfigureAwait(false); } } while (true); diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs index 496f0cdc29..2aaed750da 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs @@ -143,6 +143,8 @@ namespace Tgstation.Server.Host.Core.Tests "-27", "5000", "fake token", + //logging config + "no", //saved, now for second run //this time use defaults amap String.Empty, @@ -158,6 +160,11 @@ namespace Tgstation.Server.Host.Core.Tests String.Empty, String.Empty, String.Empty, + //logging config + "y", + "not actually verified because lol mocks /../!@#$%^&*()/..///.", + "Warning", + String.Empty, //third run, we already hit all the code coverage so just get through it String.Empty, nameof(DatabaseType.MariaDB), @@ -168,7 +175,14 @@ namespace Tgstation.Server.Host.Core.Tests "pass", String.Empty, String.Empty, - String.Empty + String.Empty, + "y", + "will faile", + String.Empty, + String.Empty, + "fake", + "None", + "Critical" }); var inputPos = 0; @@ -196,6 +210,17 @@ namespace Tgstation.Server.Host.Core.Tests //third run testGeneralConfig.SetupWizardMode = SetupWizardMode.Autodetect; mockIOManager.Setup(x => x.WriteAllBytes(It.IsNotNull(), It.IsNotNull(), It.IsAny())).Throws(new Exception()).Verifiable(); + var firstRun = true; + mockIOManager.Setup(x => x.CreateDirectory(It.IsNotNull(), It.IsAny())).Returns(() => + { + if (firstRun) + { + firstRun = false; + throw new Exception(); + } + return Task.CompletedTask; + }).Verifiable(); + await Assert.ThrowsExceptionAsync(() => wizard.CheckRunWizard(default)).ConfigureAwait(false); Assert.AreEqual(finalInputSequence.Count, inputPos); From 02aeca29ba327cf4933916d1cc9855addeb9ab6b Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 15:05:38 -0400 Subject: [PATCH 38/40] Fix FileLoggingConfiguration comments --- .../Configuration/FileLoggingConfiguration.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs index 7beb0a4477..e10a6634ed 100644 --- a/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs +++ b/src/Tgstation.Server.Host/Configuration/FileLoggingConfiguration.cs @@ -35,14 +35,14 @@ namespace Tgstation.Server.Host.Configuration public bool Disable { get; set; } /// - /// The ified minimum to display in logs + /// The minimum to display in logs /// [JsonConverter(typeof(StringEnumConverter))] public LogLevel LogLevel { get; set; } = DefaultLogLevel; /// - /// The ified minimum to display in logs for Microsoft library sources + /// The minimum to display in logs for Microsoft library sources /// [JsonConverter(typeof(StringEnumConverter))] public LogLevel MicrosoftLogLevel { get; set; } = DefaultMicrosoftLogLevel; From ded9287f4e5a851719fbde992474204b522f8b59 Mon Sep 17 00:00:00 2001 From: Cyberboss Date: Fri, 5 Oct 2018 15:08:23 -0400 Subject: [PATCH 39/40] Fixes weird coverlet failure See https://github.com/tonerdo/coverlet/issues/33 --- build/test_core.sh | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/build/test_core.sh b/build/test_core.sh index 071071c3ca..b63f1ad187 100755 --- a/build/test_core.sh +++ b/build/test_core.sh @@ -7,35 +7,29 @@ mkdir TestResults cd tests/Tgstation.Server.Api.Tests -dotnet build -c $CONFIG -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Api.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/api.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Api.Tests*]*" +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Api.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/api.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Api.Tests*]*" cd ../Tgstation.Server.Client.Tests -dotnet build -c $CONFIG -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Client.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/client.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Client.Tests*]*" +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Client.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/client.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Client.Tests*]*" cd ../Tgstation.Server.Host.Tests -dotnet build -c $CONFIG -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/host.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/host.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" cd ../Tgstation.Server.Host.Watchdog.Tests -dotnet build -c $CONFIG -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Watchdog.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/watchdog.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Watchdog.Tests*]*" +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Watchdog.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/watchdog.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Watchdog.Tests*]*" cd ../Tgstation.Server.Host.Console.Tests -dotnet build -c $CONFIG -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Console.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/console.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Console.Tests*]*" +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Console.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/console.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Console.Tests*]*" cd ../Tgstation.Server.Tests export TGS4_TEST_DATABASE_TYPE=MySql export TGS4_TEST_CONNECTION_STRING="server=127.0.0.1;uid=root;pwd=;database=tgs_test" #token set in CI settings -dotnet build -c $CONFIG -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/server.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/server.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" cd ../../TestResults From c1f3e1077df49e4b5fe9359368475cfa4c11e425 Mon Sep 17 00:00:00 2001 From: Jordan Brown Date: Fri, 5 Oct 2018 18:50:57 -0400 Subject: [PATCH 40/40] Do as I say, not as I do --- build/test_core.sh | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/build/test_core.sh b/build/test_core.sh index b63f1ad187..b361813ef7 100755 --- a/build/test_core.sh +++ b/build/test_core.sh @@ -7,29 +7,35 @@ mkdir TestResults cd tests/Tgstation.Server.Api.Tests -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Api.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/api.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Api.Tests*]*" +dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Api.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/api.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Api.Tests*]*" cd ../Tgstation.Server.Client.Tests -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Client.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/client.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Client.Tests*]*" +dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Client.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/client.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Client.Tests*]*" cd ../Tgstation.Server.Host.Tests -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/host.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" +dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/host.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" cd ../Tgstation.Server.Host.Watchdog.Tests -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Watchdog.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/watchdog.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Watchdog.Tests*]*" +dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Watchdog.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/watchdog.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Watchdog.Tests*]*" cd ../Tgstation.Server.Host.Console.Tests -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Console.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/console.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Console.Tests*]*" +dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Host.Console.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/console.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Host.Console.Tests*]*" cd ../Tgstation.Server.Tests export TGS4_TEST_DATABASE_TYPE=MySql export TGS4_TEST_CONNECTION_STRING="server=127.0.0.1;uid=root;pwd=;database=tgs_test" #token set in CI settings -$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG /p:CopyLocalLockFileAssemblies=true" --format opencover --output "../../TestResults/server.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" +dotnet build -c $CONFIG /p:CopyLocalLockFileAssemblies=true +$HOME/.dotnet/tools/coverlet bin/$CONFIG/netcoreapp2.1/Tgstation.Server.Tests.dll --target "dotnet" --targetargs "test -c $CONFIG --no-build" --format opencover --output "../../TestResults/server.xml" --include "[Tgstation.Server*]*" --exclude "[Tgstation.Server.Tests*]*" --exclude "[Tgstation.Server.Host]Tgstation.Server.Host.Models.Migrations.*" cd ../../TestResults