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/README.md b/README.md index e22e717f0b..028dcd24d1 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 @@ -52,7 +53,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 ``` @@ -60,10 +61,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, generally, 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 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"] diff --git a/build/test_core.sh b/build/test_core.sh index 071071c3ca..b361813ef7 100755 --- a/build/test_core.sh +++ b/build/test_core.sh @@ -7,34 +7,34 @@ mkdir TestResults cd tests/Tgstation.Server.Api.Tests -dotnet build -c $CONFIG +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 -dotnet build -c $CONFIG +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 -dotnet build -c $CONFIG +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 -dotnet build -c $CONFIG +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 -dotnet build -c $CONFIG +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 -dotnet build -c $CONFIG +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 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/GlobalSuppressions.cs b/src/Tgstation.Server.Host.Service/GlobalSuppressions.cs index 44ed7e1b55..47f7930b2a 100644 Binary files a/src/Tgstation.Server.Host.Service/GlobalSuppressions.cs and b/src/Tgstation.Server.Host.Service/GlobalSuppressions.cs differ diff --git a/src/Tgstation.Server.Host.Service/Program.cs b/src/Tgstation.Server.Host.Service/Program.cs index e34dcc7c1e..0c758c4de7 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; @@ -31,6 +32,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 +50,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 /// @@ -57,72 +66,80 @@ 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 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; + 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" : "-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; + } } - 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) + await watchdogFactory.CreateWatchdog(loggerFactory).RunAsync(true, Array.Empty(), default).ConfigureAwait(false); + + 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)); } /// /// 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.Service/ServerService.cs b/src/Tgstation.Server.Host.Service/ServerService.cs index 948200703d..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; @@ -27,7 +28,7 @@ namespace Tgstation.Server.Host.Service readonly IWatchdog watchdog; /// - /// The recieved from of + /// The recieved from of /// Task watchdogTask; @@ -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(args, cancellationTokenSource.Token); + watchdogTask = RunWatchdog(args, cancellationTokenSource.Token); } /// 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 + 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..9dce48ac35 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); @@ -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)); @@ -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:SetupWizardMode=Only"); + } + 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/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..e10a6634ed 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 @@ -10,6 +14,16 @@ /// 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 /// @@ -21,14 +35,16 @@ public bool Disable { get; set; } /// - /// The ified minimum to display in logs + /// The minimum to display in logs /// - public string LogLevel { get; set; } + [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 /// - public string MicrosoftLogLevel { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public LogLevel MicrosoftLogLevel { get; set; } = DefaultMicrosoftLogLevel; } } diff --git a/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs b/src/Tgstation.Server.Host/Configuration/GeneralConfiguration.cs index ccf366863c..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,14 +13,35 @@ /// 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 /// public string GitHubAccessToken { get; set; } + + /// + /// The + /// + [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 589a87a6da..ef13f01292 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -4,13 +4,12 @@ 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; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; -using Microsoft.IdentityModel.Tokens; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Serilog; @@ -56,6 +55,9 @@ namespace Tgstation.Server.Host.Core /// readonly Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment; + /// + /// The used for determining when the is + /// readonly TaskCompletionSource startupTcs; /// @@ -83,31 +85,71 @@ namespace Tgstation.Server.Host.Core if (services == null) throw new ArgumentNullException(nameof(services)); + //needful + services.AddSingleton(this); + + //configure configuration 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)); - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var ioManager = new DefaultIOManager(); + //enable options which give us config reloading + services.AddOptions(); + + //setup stuff for setup wizard + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); - //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) + //needed here for JWT configuration + //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 + //also allows us to get some options and other services we need for continued configuration + using (var provider = services.BuildServiceProvider()) { - var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration?.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs"); + //run the wizard if necessary + var setupWizard = provider.GetRequiredService(); + var applicationLifetime = provider.GetRequiredService(); + var setupWizardRan = setupWizard.CheckRunWizard(applicationLifetime.ApplicationStopping).GetAwaiter().GetResult(); - logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log"); + //load the configuration options we need + var generalOptions = provider.GetRequiredService>(); + generalConfiguration = generalOptions.Value; + //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(); + ioManager = provider.GetRequiredService(); + } + + //setup file logging via serilog + if (!fileLoggingConfiguration.Disable) services.AddLogging(builder => { - LogLevel GetMinimumLogLevel(string stringLevel) - { - if (String.IsNullOrWhiteSpace(stringLevel) || !Enum.TryParse(stringLevel, out var minimumLevel)) - minimumLevel = LogLevel.Information; - return minimumLevel; - } + //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) { @@ -132,8 +174,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); @@ -149,46 +191,22 @@ namespace Tgstation.Server.Host.Core builder.AddSerilog(configuration.CreateLogger(), true); }); - } - services.AddOptions(); - - services.AddScoped(); - - const string scheme = "JwtBearer"; - services.AddAuthentication((options) => + //configure bearer token validation + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(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) }; }); + //fucking converts 'sub' to M$ bs + //can't be done in the above lambda, that's too late + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs - + //add mvc, configure the json serializer settings services.AddMvc().AddJsonOptions(options => { options.AllowInputFormatterExceptionMessages = true; @@ -199,8 +217,6 @@ namespace Tgstation.Server.Host.Core options.SerializerSettings.Converters = new[] { new VersionConverter() }; }); - var databaseConfiguration = databaseConfigurationSection.Get(); - void AddTypedContext() where TContext : DatabaseContext { services.AddDbContext(builder => @@ -211,8 +227,9 @@ namespace Tgstation.Server.Host.Core services.AddScoped(x => x.GetRequiredService()); } - var dbType = databaseConfiguration?.DatabaseType; - switch (databaseConfiguration?.DatabaseType) + //add the correct database context type + var dbType = databaseConfiguration.DatabaseType; + switch (dbType) { case DatabaseType.MySql: case DatabaseType.MariaDB: @@ -225,19 +242,19 @@ 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(); - - if (isWindows) + //configure platform specific services + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { services.AddSingleton(); services.AddSingleton(); @@ -257,30 +274,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); } /// @@ -299,25 +315,35 @@ 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()); + //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/DBConnectionFactory.cs b/src/Tgstation.Server.Host/Core/DBConnectionFactory.cs new file mode 100644 index 0000000000..8378d28d00 --- /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 ArgumentOutOfRangeException(nameof(databaseType), databaseType, "Invalid 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/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..1d3cb0bd65 --- /dev/null +++ b/src/Tgstation.Server.Host/Core/SetupWizard.cs @@ -0,0 +1,586 @@ +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 IDBConnectionFactory dbConnectionFactory; + + /// + /// 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 value of + /// The value of + /// The containing the value of + 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)); + } + + /// + /// 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); + 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; + if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + useWinAuth = await PromptYesNo("Use Windows Authentication? (y/n): ", cancellationToken).ConfigureAwait(false); + else + useWinAuth = false; + + await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false); + + string username = null; + string password = null; + if (!useWinAuth) + { + 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); + } + 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) + { + testConnection = dbConnectionFactory.CreateConnection(connectionString, databaseConfiguration.DatabaseType); + } + + if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer) + { + var csb = new SqlConnectionStringBuilder + { + ApplicationName = application.VersionPrefix, + DataSource = serverAddress ?? "(local)" + }; + if (useWinAuth) + csb.IntegratedSecurity = true; + else + { + csb.UserID = username; + csb.Password = password; + } + + CreateTestConnection(csb.ConnectionString); + csb.InitialCatalog = databaseName; + databaseConfiguration.ConnectionString = csb.ConnectionString; + } + else + { + var csb = new MySqlConnectionStringBuilder + { + Server = serverAddress ?? "127.0.0.1", + UserID = username, + Password = password + }; + + CreateTestConnection(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 (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("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 (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("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 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}): ", 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) + { + newGeneralConfiguration.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}): ", 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) + { + newGeneralConfiguration.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); + 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; + } + //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; + } + + /// + /// Saves a given set to + /// + /// The file to save the to + /// The hosting port 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 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, newGeneralConfiguration }, + { FileLoggingConfiguration.Section, fileLoggingConfiguration } + }; + + 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 (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("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 + /// A representing the running operation + 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); + + var hostingPort = await PromptForHostingPort(cancellationToken).ConfigureAwait(false); + + var databaseConfiguration = await ConfigureDatabase(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, newGeneralConfiguration, fileLoggingConfiguration, 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 exists = await ioManager.FileExists(userConfigFileName, cancellationToken).ConfigureAwait(false); + + bool shouldRunBasedOnAutodetect; + if (exists) + { + 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); + 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; + } + + //flush the logs to prevent console conflicts + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(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..35d3b1e135 --- /dev/null +++ b/src/Tgstation.Server.Host/IO/Console.cs @@ -0,0 +1,77 @@ +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; + + void CheckAvailable() + { + if (!Available) + throw new InvalidOperationException("Console unavailable"); + } + + /// + 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(); + + 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(() => + { + CheckAvailable(); + 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); + }, 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/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 - } -} diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json index e24be80260..2da5abc3b2 100644 --- a/src/Tgstation.Server.Host/appsettings.json +++ b/src/Tgstation.Server.Host/appsettings.json @@ -1,7 +1,9 @@ { "General": { "MinimumPasswordLength": 15, - "GitHubAccessToken": null + "GitHubAccessToken": null, + "SetupWizardMode": "AutoDetect", + "ByondTopicTimeout": 5000 }, "FileLogging": { "Directory": null, //use the default path 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..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(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(); 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..f3747f0fe7 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs @@ -0,0 +1,97 @@ +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 + { + [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)); + } + + 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(); + } + } +} diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestDBConnectionFactory.cs b/tests/Tgstation.Server.Host.Tests/Core/TestDBConnectionFactory.cs new file mode 100644 index 0000000000..39abca29bb --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Core/TestDBConnectionFactory.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 TestDBConnectionFactory + { + [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)); + } + } +} 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..2aaed750da --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/Core/TestSetupWizard.cs @@ -0,0 +1,239 @@ +using Microsoft.AspNetCore.Hosting; +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; +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)); + } + + [TestMethod] + public async Task TestWithUserStupiditiy() + { + 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; + 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 mockSuccessCommand = new Mock(); + mockSuccessCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); + 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(); + + 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(); + SetDbCommandCreator(mockUglyDbConnection, () => + { + if (invokeTimes < 2) + { + ++invokeTimes; + return mockSuccessCommand.Object; + } + else + return mockFailCommand.Object; + }); + + 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)) + //test winauth + 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", + "NO", + "user", + "pass", + //general config + "four", + "-12", + "16", + "eight", + "-27", + "5000", + "fake token", + //logging config + "no", + //saved, now for second run + //this time use defaults amap + String.Empty, + //test MySQL errors + nameof(DatabaseType.MySql), + String.Empty, + String.Empty, + "DbName", + "n", + "user", + "pass", + //general config + 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), + String.Empty, + "dbname", + "y", + "user", + "pass", + String.Empty, + String.Empty, + String.Empty, + "y", + "will faile", + String.Empty, + String.Empty, + "fake", + "None", + "Critical" + }); + + 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!"); + 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)); + + //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(); + 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); + mockFailCommand.VerifyAll(); + mockSuccessCommand.VerifyAll(); + mockIOManager.VerifyAll(); + mockGeneralConfigurationOptions.VerifyAll(); + mockConsole.VerifyAll(); + mockGoodDbConnection.VerifyAll(); + mockBadDbConnection.VerifyAll(); + mockUglyDbConnection.VerifyAll(); + mockDBConnectionFactory.VerifyAll(); + mockApplication.VerifyAll(); + } + } +} 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..59335e2425 --- /dev/null +++ b/tests/Tgstation.Server.Host.Tests/IO/TestConsole.cs @@ -0,0 +1,35 @@ +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); + 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] + public void TestUserInteractive() + { + var console = new Console(); + Assert.AreEqual(Environment.UserInteractive, console.Available); + } + } +} 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); } 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))