mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-25 05:56:58 +01:00
@@ -14,3 +14,4 @@ artifacts/
|
||||
/tests/DMAPI/travistester.lk
|
||||
/tests/DMAPI/travistester.int
|
||||
/tests/DMAPI/travistester.dmb
|
||||
/src/Tgstation.Server.Host/appsettings.Development.json
|
||||
|
||||
@@ -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 <tgs port>:80 \
|
||||
-p 0.0.0.0:<public game port>:<internal game port> \
|
||||
-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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Binary file not shown.
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// The --configure or -c option
|
||||
/// </summary>
|
||||
[Option(ShortName = "c")]
|
||||
public bool Configure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The --trace or -t option. Enables trace logs
|
||||
/// </summary>
|
||||
@@ -43,6 +50,8 @@ namespace Tgstation.Server.Host.Service
|
||||
[Option(ShortName = "d")]
|
||||
public bool Debug { get; set; }
|
||||
|
||||
static readonly IWatchdogFactory watchdogFactory = new WatchdogFactory();
|
||||
|
||||
/// <summary>
|
||||
/// Check if the running user is a system administrator
|
||||
/// </summary>
|
||||
@@ -57,72 +66,80 @@ namespace Tgstation.Server.Host.Service
|
||||
/// <summary>
|
||||
/// Command line handler, always runs
|
||||
/// </summary>
|
||||
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<string>(), 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint for the application
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static int Main(string[] args) => CommandLineApplication.Execute<Program>(args);
|
||||
static Task<int> Main(string[] args) => CommandLineApplication.ExecuteAsync<Program>(args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="Task"/> recieved from <see cref="IWatchdog.RunAsync(string[], CancellationToken)"/> of <see cref="watchdog"/>
|
||||
/// The <see cref="Task"/> recieved from <see cref="IWatchdog.RunAsync(bool, string[], CancellationToken)"/> of <see cref="watchdog"/>
|
||||
/// </summary>
|
||||
Task watchdogTask;
|
||||
|
||||
@@ -65,6 +66,31 @@ namespace Tgstation.Server.Host.Service
|
||||
/// <inheritdoc />
|
||||
public void WriteEntry(string message, EventLogEntryType type, int eventID, short category) => EventLog.WriteEntry(message, type, eventID, category);
|
||||
|
||||
/// <summary>
|
||||
/// Executes the <see cref="watchdog"/>, stopping the service if it exits
|
||||
/// </summary>
|
||||
/// <param name="args">The arguments for the <see cref="watchdog"/></param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
<StartupObject>Tgstation.Server.Host.Service.Program</StartupObject>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup />
|
||||
<PropertyGroup>
|
||||
<ApplicationIcon>../../build/tgs.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="GlobalSuppressions.cs" />
|
||||
<Compile Include="Program.cs" />
|
||||
|
||||
@@ -11,9 +11,10 @@ namespace Tgstation.Server.Host.Watchdog
|
||||
/// <summary>
|
||||
/// Run the <see cref="IWatchdog"/>
|
||||
/// </summary>
|
||||
/// <param name="runConfigure">If the <see cref="IWatchdog"/> should just run the host configuration wizard and exit</param>
|
||||
/// <param name="args">The arguments for the <see cref="IWatchdog"/></param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task RunAsync(string[] args, CancellationToken cancellationToken);
|
||||
Task RunAsync(bool runConfigure, string[] args, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace Tgstation.Server.Host.Watchdog
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
namespace Tgstation.Server.Host.Configuration
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace Tgstation.Server.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration options for the <see cref="Models.DatabaseContext{TParentContext}"/>
|
||||
@@ -13,6 +16,7 @@
|
||||
/// <summary>
|
||||
/// The <see cref="Configuration.DatabaseType"/> to create
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public DatabaseType DatabaseType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// File logging configuration options
|
||||
@@ -10,6 +14,16 @@
|
||||
/// </summary>
|
||||
public const string Section = "FileLogging";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="LogLevel"/>
|
||||
/// </summary>
|
||||
const LogLevel DefaultLogLevel = LogLevel.Debug;
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="MicrosoftLogLevel"/>
|
||||
/// </summary>
|
||||
const LogLevel DefaultMicrosoftLogLevel = LogLevel.Warning;
|
||||
|
||||
/// <summary>
|
||||
/// Where log files are stored
|
||||
/// </summary>
|
||||
@@ -21,14 +35,16 @@
|
||||
public bool Disable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="string"/>ified minimum <see cref="Microsoft.Extensions.Logging.LogLevel"/> to display in logs
|
||||
/// The minimum <see cref="Microsoft.Extensions.Logging.LogLevel"/> to display in logs
|
||||
/// </summary>
|
||||
public string LogLevel { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public LogLevel LogLevel { get; set; } = DefaultLogLevel;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="string"/>ified minimum <see cref="Microsoft.Extensions.Logging.LogLevel"/> to display in logs for Microsoft library sources
|
||||
/// The minimum <see cref="Microsoft.Extensions.Logging.LogLevel"/> to display in logs for Microsoft library sources
|
||||
/// </summary>
|
||||
public string MicrosoftLogLevel { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public LogLevel MicrosoftLogLevel { get; set; } = DefaultMicrosoftLogLevel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
namespace Tgstation.Server.Host.Configuration
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace Tgstation.Server.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// General configuration options
|
||||
@@ -10,14 +13,35 @@
|
||||
/// </summary>
|
||||
public const string Section = "General";
|
||||
|
||||
/// <summary>
|
||||
/// The default value for <see cref="MinimumPasswordLength"/>
|
||||
/// </summary>
|
||||
const uint DefaultMinimumPasswordLength = 15;
|
||||
|
||||
/// <summary>
|
||||
/// The default value for <see cref="ByondTopicTimeout"/>
|
||||
/// </summary>
|
||||
const int DefaultByondTopicTimeout = 5000;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum length of database user passwords
|
||||
/// </summary>
|
||||
public uint MinimumPasswordLength { get; set; }
|
||||
public uint MinimumPasswordLength { get; set; } = DefaultMinimumPasswordLength;
|
||||
|
||||
/// <summary>
|
||||
/// A GitHub personal access token to use for bypassing rate limits on requests. Requires no scopes
|
||||
/// </summary>
|
||||
public string GitHubAccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="SetupWizardMode"/>
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public SetupWizardMode SetupWizardMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public int ByondTopicTimeout { get; set; } = DefaultByondTopicTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Tgstation.Server.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the <see cref="Core.ISetupWizard"/> will run
|
||||
/// </summary>
|
||||
public enum SetupWizardMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the wizard if the appsettings.{Environment}.json is not present or empty
|
||||
/// </summary>
|
||||
Autodetect,
|
||||
/// <summary>
|
||||
/// Force run the wizard
|
||||
/// </summary>
|
||||
Force,
|
||||
/// <summary>
|
||||
/// Only run the wizard and exit
|
||||
/// </summary>
|
||||
Only,
|
||||
/// <summary>
|
||||
/// Never run the wizard
|
||||
/// </summary>
|
||||
Never
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
readonly Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="TaskCompletionSource{TResult}"/> used for determining when the <see cref="Application"/> is <see cref="Ready(Exception)"/>
|
||||
/// </summary>
|
||||
readonly TaskCompletionSource<object> startupTcs;
|
||||
|
||||
/// <summary>
|
||||
@@ -83,31 +85,71 @@ namespace Tgstation.Server.Host.Core
|
||||
if (services == null)
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
|
||||
//needful
|
||||
services.AddSingleton<IApplication>(this);
|
||||
|
||||
//configure configuration
|
||||
services.Configure<UpdatesConfiguration>(configuration.GetSection(UpdatesConfiguration.Section));
|
||||
var databaseConfigurationSection = configuration.GetSection(DatabaseConfiguration.Section);
|
||||
services.Configure<DatabaseConfiguration>(databaseConfigurationSection);
|
||||
services.Configure<DatabaseConfiguration>(configuration.GetSection(DatabaseConfiguration.Section));
|
||||
services.Configure<GeneralConfiguration>(configuration.GetSection(GeneralConfiguration.Section));
|
||||
services.Configure<FileLoggingConfiguration>(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<IIOManager, DefaultIOManager>();
|
||||
services.AddSingleton<IConsole, IO.Console>();
|
||||
services.AddSingleton<IDBConnectionFactory, DBConnectionFactory>();
|
||||
services.AddSingleton<ISetupWizard, SetupWizard>();
|
||||
|
||||
//remember, anything you .Get manually can be null if the config is missing
|
||||
var fileLoggingConfigurationSection = configuration.GetSection(FileLoggingConfiguration.Section);
|
||||
var fileLoggingConfiguration = fileLoggingConfigurationSection.Get<FileLoggingConfiguration>();
|
||||
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<ITokenFactory>(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<ISetupWizard>();
|
||||
var applicationLifetime = provider.GetRequiredService<Microsoft.AspNetCore.Hosting.IApplicationLifetime>();
|
||||
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<IOptions<GeneralConfiguration>>();
|
||||
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<IOptions<DatabaseConfiguration>>();
|
||||
databaseConfiguration = dbOptions.Value;
|
||||
|
||||
var loggingOptions = provider.GetRequiredService<IOptions<FileLoggingConfiguration>>();
|
||||
fileLoggingConfiguration = loggingOptions.Value;
|
||||
|
||||
tokenFactory = provider.GetRequiredService<ITokenFactory>();
|
||||
ioManager = provider.GetRequiredService<IIOManager>();
|
||||
}
|
||||
|
||||
//setup file logging via serilog
|
||||
if (!fileLoggingConfiguration.Disable)
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
LogLevel GetMinimumLogLevel(string stringLevel)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(stringLevel) || !Enum.TryParse<LogLevel>(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<IClaimsInjector, ClaimsInjector>();
|
||||
|
||||
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<IClaimsInjector>().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<DatabaseConfiguration>();
|
||||
|
||||
void AddTypedContext<TContext>() where TContext : DatabaseContext<TContext>
|
||||
{
|
||||
services.AddDbContext<TContext>(builder =>
|
||||
@@ -211,8 +227,9 @@ namespace Tgstation.Server.Host.Core
|
||||
services.AddScoped<IDatabaseContext>(x => x.GetRequiredService<TContext>());
|
||||
}
|
||||
|
||||
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<IAuthenticationContextFactory, AuthenticationContextFactory>();
|
||||
services.AddSingleton<IIdentityCache, IdentityCache>();
|
||||
|
||||
services.AddSingleton<ICryptographySuite, CryptographySuite>();
|
||||
//configure other database services
|
||||
services.AddSingleton<IDatabaseContextFactory, DatabaseContextFactory>();
|
||||
services.AddSingleton<IDatabaseSeeder, DatabaseSeeder>();
|
||||
|
||||
//configure security services
|
||||
services.AddScoped<IAuthenticationContextFactory, AuthenticationContextFactory>();
|
||||
services.AddScoped<IClaimsInjector, ClaimsInjector>();
|
||||
services.AddSingleton<IIdentityCache, IdentityCache>();
|
||||
services.AddSingleton<ICryptographySuite, CryptographySuite>();
|
||||
services.AddSingleton<IPasswordHasher<Models.User>, PasswordHasher<Models.User>>();
|
||||
services.AddSingleton<ITokenFactory, TokenFactory>();
|
||||
services.AddSingleton<ISynchronousIOManager, SynchronousIOManager>();
|
||||
services.AddSingleton<ICredentialsProvider, CredentialsProvider>();
|
||||
|
||||
services.AddSingleton<IGitHubClientFactory, GitHubClientFactory>();
|
||||
|
||||
if (isWindows)
|
||||
//configure platform specific services
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
services.AddSingleton<ISystemIdentityFactory, WindowsSystemIdentityFactory>();
|
||||
services.AddSingleton<ISymlinkFactory, WindowsSymlinkFactory>();
|
||||
@@ -257,30 +274,29 @@ namespace Tgstation.Server.Host.Core
|
||||
services.AddSingleton<INetworkPromptReaper, PosixNetworkPromptReaper>();
|
||||
}
|
||||
|
||||
//configure misc services
|
||||
services.AddSingleton<ISynchronousIOManager, SynchronousIOManager>();
|
||||
services.AddSingleton<IGitHubClientFactory, GitHubClientFactory>();
|
||||
services.AddSingleton<IProcessExecutor, ProcessExecutor>();
|
||||
services.AddSingleton<IProviderFactory, ProviderFactory>();
|
||||
services.AddSingleton<IByondTopicSender>(new ByondTopicSender
|
||||
{
|
||||
ReceiveTimeout = 5000,
|
||||
SendTimeout = 5000
|
||||
ReceiveTimeout = generalConfiguration.ByondTopicTimeout,
|
||||
SendTimeout = generalConfiguration.ByondTopicTimeout
|
||||
});
|
||||
|
||||
//configure component services
|
||||
services.AddSingleton<ICredentialsProvider, CredentialsProvider>();
|
||||
services.AddSingleton<IProviderFactory, ProviderFactory>();
|
||||
services.AddSingleton<IChatFactory, ChatFactory>();
|
||||
services.AddSingleton<IWatchdogFactory, WatchdogFactory>();
|
||||
services.AddSingleton<IInstanceFactory, InstanceFactory>();
|
||||
|
||||
//configure root services
|
||||
services.AddSingleton<InstanceManager>();
|
||||
services.AddSingleton<IInstanceManager>(x => x.GetRequiredService<InstanceManager>());
|
||||
services.AddSingleton<IHostedService>(x => x.GetRequiredService<InstanceManager>());
|
||||
|
||||
services.AddSingleton<IJobManager, JobManager>();
|
||||
|
||||
services.AddSingleton<IIOManager>(ioManager);
|
||||
|
||||
services.AddSingleton<DatabaseContextFactory>();
|
||||
services.AddSingleton<IDatabaseContextFactory>(x => x.GetRequiredService<DatabaseContextFactory>());
|
||||
|
||||
services.AddSingleton<IApplication>(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
sealed class DBConnectionFactory : IDBConnectionFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Data.Common;
|
||||
using Tgstation.Server.Host.Configuration;
|
||||
|
||||
namespace Tgstation.Server.Host.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// For creating <see cref="DbConnection"/>
|
||||
/// </summary>
|
||||
interface IDBConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a <see cref="DbConnection"/>
|
||||
/// </summary>
|
||||
/// <param name="connectionString">The <see cref="DbConnection.ConnectionString"/></param>
|
||||
/// <param name="databaseType">The <see cref="DatabaseType"/> to create</param>
|
||||
/// <returns>A new <see cref="DbConnection"/></returns>
|
||||
DbConnection CreateConnection(string connectionString, DatabaseType databaseType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Host.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// The command line <see cref="Configuration"/> setup wizard
|
||||
/// </summary>
|
||||
interface ISetupWizard
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the setup wizard if necessary
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> CheckRunWizard(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
sealed class SetupWizard : ISetupWizard
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="IIOManager"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly IIOManager ioManager;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IConsole"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly IConsole console;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IHostingEnvironment"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly IHostingEnvironment hostingEnvironment;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IApplication"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly IApplication application;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IDBConnectionFactory"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly IDBConnectionFactory dbConnectionFactory;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="ILogger"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly ILogger<SetupWizard> logger;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="GeneralConfiguration"/> for the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
readonly GeneralConfiguration generalConfiguration;
|
||||
|
||||
/// <summary>
|
||||
/// Construct a <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
|
||||
/// <param name="console">The value of <see cref="console"/></param>
|
||||
/// <param name="hostingEnvironment">The value of <see cref="hostingEnvironment"/></param>
|
||||
/// <param name="application">The value of <see cref="application"/></param>
|
||||
/// <param name="dbConnectionFactory">The value of <see cref="dbConnectionFactory"/></param>
|
||||
/// <param name="logger">The value of <see cref="logger"/></param>
|
||||
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/></param>
|
||||
public SetupWizard(IIOManager ioManager, IConsole console, IHostingEnvironment hostingEnvironment, IApplication application, IDBConnectionFactory dbConnectionFactory, ILogger<SetupWizard> logger, IOptions<GeneralConfiguration> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A prompt for a yes or no value
|
||||
/// </summary>
|
||||
/// <param name="question">The question <see cref="string"/></param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> resulting in <see langword="true"/> if the user replied yes, <see langword="false"/> otherwise</returns>
|
||||
async Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user to enter the port to host TGS on
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> resulting in the hosting port, or <see langword="null"/> to use the default</returns>
|
||||
async Task<ushort?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user to create a <see cref="DatabaseConfiguration"/>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="DatabaseConfiguration"/></returns>
|
||||
async Task<DatabaseConfiguration> 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<DatabaseType>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user to create a <see cref="GeneralConfiguration"/>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="GeneralConfiguration"/></returns>
|
||||
async Task<GeneralConfiguration> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prompts the user to create a <see cref="FileLoggingConfiguration"/>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the new <see cref="FileLoggingConfiguration"/></returns>
|
||||
async Task<FileLoggingConfiguration> 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<byte>(), 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<LogLevel?> 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<LogLevel>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a given <see cref="Configuration"/> set to <paramref name="userConfigFileName"/>
|
||||
/// </summary>
|
||||
/// <param name="userConfigFileName">The file to save the <see cref="Configuration"/> to</param>
|
||||
/// <param name="hostingPort">The hosting port to save</param>
|
||||
/// <param name="databaseConfiguration">The <see cref="DatabaseConfiguration"/> to save</param>
|
||||
/// <param name="newGeneralConfiguration">The <see cref="GeneralConfiguration"/> to save</param>
|
||||
/// <param name="fileLoggingConfiguration">The <see cref="FileLoggingConfiguration"/> to save</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
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<string, object>()
|
||||
{
|
||||
{ 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the <see cref="SetupWizard"/>
|
||||
/// </summary>
|
||||
/// <param name="userConfigFileName">The path to the settings json to build</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Host.IO
|
||||
{
|
||||
/// <inheritdoc />
|
||||
sealed class Console : IConsole
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool Available => Environment.UserInteractive;
|
||||
|
||||
void CheckAvailable()
|
||||
{
|
||||
if (!Available)
|
||||
throw new InvalidOperationException("Console unavailable");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
|
||||
{
|
||||
CheckAvailable();
|
||||
System.Console.Read();
|
||||
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> 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);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Tgstation.Server.Host.IO
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction for <see cref="System.Console"/>
|
||||
/// </summary>
|
||||
interface IConsole
|
||||
{
|
||||
/// <summary>
|
||||
/// If the <see cref="IConsole"/> is visible to the user
|
||||
/// </summary>
|
||||
bool Available { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Write some <paramref name="text"/> to the <see cref="IConsole"/>
|
||||
/// </summary>
|
||||
/// <param name="text">The <see cref="string"/> to write</param>
|
||||
/// <param name="newLine">If there should be a new line after the <paramref name="text"/></param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task WriteAsync(string text, bool newLine, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Wait for a key press on the <see cref="IConsole"/>
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operations</param>
|
||||
/// <returns>A <see cref="Task"/> representing the running operation</returns>
|
||||
Task PressAnyKeyAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read a line from the <see cref="IConsole"/>
|
||||
/// </summary>
|
||||
/// <param name="usePasswordChar">If the input should be retrieved using the '*' character</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="string"/> read by the <see cref="IConsole"/></returns>
|
||||
Task<string> ReadLineAsync(bool usePasswordChar, CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public interface ITokenFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="TokenValidationParameters"/> for the <see cref="ITokenFactory"/>
|
||||
/// </summary>
|
||||
TokenValidationParameters ValidationParameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Create a <see cref="Token"/> for a given <paramref name="user"/>
|
||||
/// </summary>
|
||||
|
||||
@@ -16,9 +16,33 @@ namespace Tgstation.Server.Host.Security
|
||||
/// </summary>
|
||||
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);
|
||||
/// <inheritdoc />
|
||||
public TokenValidationParameters ValidationParameters { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Construct a <see cref="TokenFactory"/>
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings.Development.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"FileLogging": {
|
||||
"Disable": true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"General": {
|
||||
"MinimumPasswordLength": 15,
|
||||
"GitHubAccessToken": null
|
||||
"GitHubAccessToken": null,
|
||||
"SetupWizardMode": "AutoDetect",
|
||||
"ByondTopicTimeout": 5000
|
||||
},
|
||||
"FileLogging": {
|
||||
"Directory": null, //use the default path
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Tgstation.Server.Host.Console.Tests
|
||||
{
|
||||
var mockServer = new Mock<IWatchdog>();
|
||||
var args = Array.Empty<string>();
|
||||
mockServer.Setup(x => x.RunAsync(args, It.IsAny<CancellationToken>())).Returns(Task.CompletedTask).Verifiable();
|
||||
mockServer.Setup(x => x.RunAsync(false, args, It.IsAny<CancellationToken>())).Returns(Task.CompletedTask).Verifiable();
|
||||
var mockServerFactory = new Mock<IWatchdogFactory>();
|
||||
mockServerFactory.Setup(x => x.CreateWatchdog(It.IsAny<ILoggerFactory>())).Returns(mockServer.Object).Verifiable();
|
||||
Program.WatchdogFactory = mockServerFactory.Object;
|
||||
|
||||
@@ -35,7 +35,7 @@ namespace Tgstation.Server.Host.Service.Tests
|
||||
var mockWatchdog = new Mock<IWatchdog>();
|
||||
var args = Array.Empty<string>();
|
||||
CancellationToken cancellationToken;
|
||||
mockWatchdog.Setup(x => x.RunAsync(args, It.IsAny<CancellationToken>())).Callback((string[] _, CancellationToken token) => cancellationToken = token).Returns(Task.CompletedTask).Verifiable();
|
||||
mockWatchdog.Setup(x => x.RunAsync(false, args, It.IsAny<CancellationToken>())).Callback((bool x, string[] _, CancellationToken token) => cancellationToken = token).Returns(Task.CompletedTask).Verifiable();
|
||||
var mockWatchdogFactory = new Mock<IWatchdogFactory>();
|
||||
var mockLoggerFactory = new LoggerFactory();
|
||||
mockWatchdogFactory.Setup(x => x.CreateWatchdog(mockLoggerFactory)).Returns(mockWatchdog.Object).Verifiable();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="Application"/>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class TestApplication
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestMethodThrows()
|
||||
{
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new Application(null, null));
|
||||
var mockConfiguration = new Mock<IConfiguration>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new Application(mockConfiguration.Object, null));
|
||||
|
||||
var mockHostingEnvironment = new Mock<IHostingEnvironment>();
|
||||
|
||||
var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object);
|
||||
|
||||
Assert.ThrowsException<ArgumentNullException>(() => app.ConfigureServices(null));
|
||||
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(null, null, null));
|
||||
|
||||
var mockAppBuilder = new Mock<IApplicationBuilder>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, null, null));
|
||||
|
||||
var mockLogger = new Mock<ILogger<Application>>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(mockAppBuilder.Object, mockLogger.Object, null));
|
||||
}
|
||||
|
||||
class MockSetupWizard : ISetupWizard
|
||||
{
|
||||
public Task<bool> 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<IConfiguration>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new Application(mockConfiguration.Object, null));
|
||||
|
||||
var mockHostingEnvironment = new Mock<IHostingEnvironment>();
|
||||
|
||||
var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object);
|
||||
|
||||
var mockOptions = new Mock<IOptions<GeneralConfiguration>>();
|
||||
mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration
|
||||
{
|
||||
SetupWizardMode = SetupWizardMode.Only
|
||||
}).Verifiable();
|
||||
|
||||
var fakeServiceDescriptor = new List<ServiceDescriptor>()
|
||||
{
|
||||
new ServiceDescriptor(typeof(IApplicationLifetime), typeof(MockApplicationLifetime), ServiceLifetime.Singleton),
|
||||
new ServiceDescriptor(typeof(ISetupWizard), typeof(MockSetupWizard), ServiceLifetime.Singleton),
|
||||
new ServiceDescriptor(typeof(IOptions<GeneralConfiguration>), mockOptions.Object)
|
||||
};
|
||||
|
||||
var mockServiceCollection = new Mock<IServiceCollection>();
|
||||
|
||||
var mockConfigSection = new Mock<IConfigurationSection>();
|
||||
|
||||
mockConfiguration.Setup(x => x.GetSection(It.IsNotNull<string>())).Returns(mockConfigSection.Object).Verifiable();
|
||||
mockServiceCollection.Setup(x => x.GetEnumerator()).Returns(() => fakeServiceDescriptor.GetEnumerator()).Verifiable();
|
||||
|
||||
Assert.ThrowsException<OperationCanceledException>(() => app.ConfigureServices(mockServiceCollection.Object));
|
||||
|
||||
mockOptions.VerifyAll();
|
||||
mockConfiguration.VerifyAll();
|
||||
mockServiceCollection.VerifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentNullException>(() => factory.CreateConnection(null, default));
|
||||
Assert.ThrowsException<ArgumentOutOfRangeException>(() => 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentNullException>(() => new SetupWizard(null, null, null, null, null, null, null));
|
||||
var mockIOManager = new Mock<IIOManager>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new SetupWizard(mockIOManager.Object, null, null, null, null, null, null));
|
||||
var mockConsole = new Mock<IConsole>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, null, null, null, null, null));
|
||||
var mockHostingEnvironment = new Mock<IHostingEnvironment>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, null, null, null, null));
|
||||
var mockApplication = new Mock<IApplication>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, null, null, null));
|
||||
var mockDBConnectionFactory = new Mock<IDBConnectionFactory>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, mockDBConnectionFactory.Object, null, null));
|
||||
var mockLogger = new Mock<ILogger<SetupWizard>>();
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new SetupWizard(mockIOManager.Object, mockConsole.Object, mockHostingEnvironment.Object, mockApplication.Object, mockDBConnectionFactory.Object, mockLogger.Object, null));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestWithUserStupiditiy()
|
||||
{
|
||||
var mockIOManager = new Mock<IIOManager>();
|
||||
var mockConsole = new Mock<IConsole>();
|
||||
var mockHostingEnvironment = new Mock<IHostingEnvironment>();
|
||||
var mockApplication = new Mock<IApplication>();
|
||||
var mockDBConnectionFactory = new Mock<IDBConnectionFactory>();
|
||||
var mockLogger = new Mock<ILogger<SetupWizard>>();
|
||||
var mockGeneralConfigurationOptions = new Mock<IOptions<GeneralConfiguration>>();
|
||||
|
||||
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<InvalidOperationException>(() => wizard.CheckRunWizard(default)).ConfigureAwait(false);
|
||||
|
||||
testGeneralConfig.SetupWizardMode = SetupWizardMode.Only;
|
||||
await Assert.ThrowsExceptionAsync<InvalidOperationException>(() => wizard.CheckRunWizard(default)).ConfigureAwait(false);
|
||||
|
||||
mockConsole.SetupGet(x => x.Available).Returns(true).Verifiable();
|
||||
mockIOManager.Setup(x => x.FileExists(It.IsNotNull<string>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(true)).Verifiable();
|
||||
mockIOManager.Setup(x => x.ReadAllBytes(It.IsNotNull<string>(), It.IsAny<CancellationToken>())).Returns(Task.FromResult(Encoding.UTF8.GetBytes("cucked"))).Verifiable();
|
||||
mockIOManager.Setup(x => x.WriteAllBytes(It.IsNotNull<string>(), It.IsNotNull<byte[]>(), It.IsAny<CancellationToken>())).Returns(Task.CompletedTask).Verifiable();
|
||||
|
||||
var mockSuccessCommand = new Mock<DbCommand>();
|
||||
mockSuccessCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult(0)).Verifiable();
|
||||
mockSuccessCommand.Setup(x => x.ExecuteScalarAsync(It.IsAny<CancellationToken>())).Returns(Task.FromResult<object>("1.2.3")).Verifiable();
|
||||
var mockFailCommand = new Mock<DbCommand>();
|
||||
mockFailCommand.Setup(x => x.ExecuteNonQueryAsync(It.IsAny<CancellationToken>())).Throws(new Exception()).Verifiable();
|
||||
|
||||
void SetDbCommandCreator(Mock<DbConnection> mock, Func<DbCommand> creator) => mock.Protected().Setup<DbCommand>("CreateDbCommand").Returns(creator).Verifiable();
|
||||
|
||||
var mockGoodDbConnection = new Mock<DbConnection>();
|
||||
mockGoodDbConnection.Setup(x => x.OpenAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask).Verifiable();
|
||||
SetDbCommandCreator(mockGoodDbConnection, () => mockSuccessCommand.Object);
|
||||
|
||||
var mockBadDbConnection = new Mock<DbConnection>();
|
||||
mockBadDbConnection.Setup(x => x.OpenAsync(It.IsAny<CancellationToken>())).Throws(new Exception()).Verifiable();
|
||||
var invokeTimes = 0;
|
||||
var mockUglyDbConnection = new Mock<DbConnection>();
|
||||
SetDbCommandCreator(mockUglyDbConnection, () =>
|
||||
{
|
||||
if (invokeTimes < 2)
|
||||
{
|
||||
++invokeTimes;
|
||||
return mockSuccessCommand.Object;
|
||||
}
|
||||
else
|
||||
return mockFailCommand.Object;
|
||||
});
|
||||
|
||||
mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny<string>(), DatabaseType.SqlServer)).Returns(mockBadDbConnection.Object).Verifiable();
|
||||
mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny<string>(), DatabaseType.MariaDB)).Returns(mockGoodDbConnection.Object).Verifiable();
|
||||
mockDBConnectionFactory.Setup(x => x.CreateConnection(It.IsAny<string>(), DatabaseType.MySql)).Returns(mockUglyDbConnection.Object).Verifiable();
|
||||
|
||||
var finalInputSequence = new List<string>()
|
||||
{
|
||||
//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<string>
|
||||
{
|
||||
"username",
|
||||
"password"
|
||||
});
|
||||
finalInputSequence.AddRange(new List<string>
|
||||
{
|
||||
//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<CancellationToken>())).Returns(Task.CompletedTask).Verifiable();
|
||||
mockConsole.Setup(x => x.ReadLineAsync(It.IsAny<bool>(), It.IsAny<CancellationToken>())).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<string>(), It.IsAny<bool>(), It.IsAny<CancellationToken>())).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<string>(), It.IsAny<CancellationToken>())).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<string>(), It.IsNotNull<byte[]>(), It.IsAny<CancellationToken>())).Throws(new Exception()).Verifiable();
|
||||
var firstRun = true;
|
||||
mockIOManager.Setup(x => x.CreateDirectory(It.IsNotNull<string>(), It.IsAny<CancellationToken>())).Returns(() =>
|
||||
{
|
||||
if (firstRun)
|
||||
{
|
||||
firstRun = false;
|
||||
throw new Exception();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}).Verifiable();
|
||||
|
||||
await Assert.ThrowsExceptionAsync<OperationCanceledException>(() => 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UnauthorizedException>(() => 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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user