mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-25 14:06:41 +01:00
Big one
Added Byond.TopicSender timeout configuration to general configuration Added IConsole Changed up ITokenFactory to be the source of TokenValidationParameters Remove unused usings Refactored SetupWizard into it's own service Greatly organized and commented Application Added SetupWizardMode GeneralConfiguration, removed ConfigCheckOnly Added DefaultMinimumPasswordLength to GeneralConfiguration
This commit is contained in:
@@ -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,10 +13,20 @@
|
||||
/// </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
|
||||
@@ -21,8 +34,14 @@
|
||||
public string GitHubAccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If the <see cref="Core.Application"/> should just check if the configuration wizard needs to be run and then exit
|
||||
/// The <see cref="SetupWizardMode"/>
|
||||
/// </summary>
|
||||
public bool ConfigCheckOnly { get; set; }
|
||||
[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
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,16 @@ using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MySql.Data.MySqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Display;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Data.SqlClient;
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Host.Components;
|
||||
using Tgstation.Server.Host.Components.Byond;
|
||||
@@ -82,383 +76,81 @@ namespace Tgstation.Server.Host.Core
|
||||
VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version);
|
||||
}
|
||||
|
||||
bool CheckRunSetupWizard(IIOManager ioManager)
|
||||
{
|
||||
if (!Environment.UserInteractive)
|
||||
return false;
|
||||
var userConfigFileName = String.Format(CultureInfo.InvariantCulture, "appsettings.{0}.json", hostingEnvironment.EnvironmentName);
|
||||
var existenceTask = ioManager.FileExists(userConfigFileName, default);
|
||||
var exists = existenceTask.GetAwaiter().GetResult();
|
||||
|
||||
if (exists)
|
||||
{
|
||||
var readTask = ioManager.ReadAllBytes(userConfigFileName, default);
|
||||
var bytes = readTask.GetAwaiter().GetResult();
|
||||
var contents = Encoding.UTF8.GetString(bytes);
|
||||
if (!String.IsNullOrWhiteSpace(contents))
|
||||
return false;
|
||||
}
|
||||
|
||||
//non-present or empty config json
|
||||
//make our own with blackjack and hookers
|
||||
|
||||
Console.WriteLine("Welcome to tgstation-server 4!");
|
||||
Console.WriteLine("This wizard will help you configure your server.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("What port would you like to connect to TGS on?");
|
||||
Console.WriteLine("Note: If this is a docker container with the default port already mapped, use the default.");
|
||||
|
||||
ushort? port = null;
|
||||
do
|
||||
{
|
||||
Console.Write("API Port (leave blank for default): ");
|
||||
var portString = Console.ReadLine();
|
||||
if (String.IsNullOrWhiteSpace(portString))
|
||||
break;
|
||||
if (UInt16.TryParse(portString, out var concretePort) && concretePort != 0)
|
||||
{
|
||||
port = concretePort;
|
||||
break;
|
||||
}
|
||||
Console.WriteLine("Invalid port! Please enter a value between 1 and 65535");
|
||||
}
|
||||
while (true);
|
||||
|
||||
|
||||
string GetPassword()
|
||||
{
|
||||
var pwd = new StringBuilder();
|
||||
do
|
||||
{
|
||||
var i = Console.ReadKey(true);
|
||||
if (i.Key == ConsoleKey.Enter)
|
||||
break;
|
||||
else if (i.Key == ConsoleKey.Backspace)
|
||||
{
|
||||
if (pwd.Length > 0)
|
||||
{
|
||||
--pwd.Length;
|
||||
Console.Write("\b \b");
|
||||
}
|
||||
}
|
||||
else if (i.KeyChar != '\u0000') // KeyChar == '\u0000' if the key pressed does not correspond to a printable character, e.g. F1, Pause-Break, etc
|
||||
{
|
||||
pwd.Append(i.KeyChar);
|
||||
Console.Write("*");
|
||||
}
|
||||
}
|
||||
while (true);
|
||||
Console.WriteLine();
|
||||
return pwd.ToString();
|
||||
}
|
||||
|
||||
DatabaseConfiguration databaseConfiguration;
|
||||
do
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("What SQL database type will you be using?");
|
||||
|
||||
databaseConfiguration = new DatabaseConfiguration();
|
||||
do
|
||||
{
|
||||
Console.Write(String.Format(CultureInfo.InvariantCulture, "Please enter one of {0}, {1}, or {2}: ", DatabaseType.MariaDB, DatabaseType.SqlServer, DatabaseType.MySql));
|
||||
var databaseTypeString = Console.ReadLine();
|
||||
if (Enum.TryParse<DatabaseType>(databaseTypeString, out var databaseType))
|
||||
{
|
||||
databaseConfiguration.DatabaseType = databaseType;
|
||||
break;
|
||||
}
|
||||
Console.WriteLine("Invalid database type!");
|
||||
}
|
||||
while (true);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.Write("Enter the server's address and port (blank for local): ");
|
||||
var serverAddress = Console.ReadLine();
|
||||
if (String.IsNullOrWhiteSpace(serverAddress))
|
||||
serverAddress = null;
|
||||
|
||||
Console.WriteLine();
|
||||
Console.Write("Enter the database name (Can be from previous installation. Otherwise, should not exist): ");
|
||||
var databaseName = Console.ReadLine();
|
||||
|
||||
bool dbExists;
|
||||
do
|
||||
{
|
||||
Console.Write("Does this database already exist? (y/n): ");
|
||||
var responseString = Console.ReadLine();
|
||||
var upperResponse = responseString.ToUpperInvariant();
|
||||
if (upperResponse == "Y" || upperResponse == "YES")
|
||||
{
|
||||
dbExists = true;
|
||||
break;
|
||||
}
|
||||
else if (upperResponse == "N" || upperResponse == "NO")
|
||||
{
|
||||
dbExists = false;
|
||||
break;
|
||||
}
|
||||
Console.WriteLine("Invalid response!");
|
||||
}
|
||||
while (true);
|
||||
|
||||
bool? useWinAuth;
|
||||
if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
do
|
||||
{
|
||||
Console.Write("Use Windows Authentication? (y/n): ");
|
||||
var responseString = Console.ReadLine();
|
||||
var upperResponse = responseString.ToUpperInvariant();
|
||||
if (upperResponse == "Y" || upperResponse == "YES")
|
||||
{
|
||||
useWinAuth = true;
|
||||
break;
|
||||
}
|
||||
else if (upperResponse == "N" || upperResponse == "NO")
|
||||
{
|
||||
useWinAuth = false;
|
||||
break;
|
||||
}
|
||||
Console.WriteLine("Invalid response!");
|
||||
}
|
||||
while (true);
|
||||
else
|
||||
useWinAuth = null;
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
string username = null;
|
||||
string password = null;
|
||||
if (useWinAuth != true)
|
||||
{
|
||||
Console.Write("Enter username: ");
|
||||
username = Console.ReadLine();
|
||||
Console.Write("Enter password: ");
|
||||
password = GetPassword();
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
IDbConnection testConnection;
|
||||
if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer)
|
||||
{
|
||||
var csb = new SqlConnectionStringBuilder
|
||||
{
|
||||
ApplicationName = VersionPrefix,
|
||||
DataSource = serverAddress ?? "(local)"
|
||||
};
|
||||
if (useWinAuth.Value)
|
||||
csb.IntegratedSecurity = true;
|
||||
else
|
||||
{
|
||||
csb.UserID = username;
|
||||
csb.Password = password;
|
||||
}
|
||||
testConnection = new SqlConnection
|
||||
{
|
||||
ConnectionString = csb.ConnectionString
|
||||
};
|
||||
csb.InitialCatalog = databaseName;
|
||||
databaseConfiguration.ConnectionString = csb.ConnectionString;
|
||||
}
|
||||
else
|
||||
{
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = serverAddress ?? "127.0.0.1",
|
||||
UserID = username,
|
||||
Password = password
|
||||
};
|
||||
testConnection = new MySqlConnection
|
||||
{
|
||||
ConnectionString = csb.ConnectionString
|
||||
};
|
||||
csb.Database = databaseName;
|
||||
databaseConfiguration.ConnectionString = csb.ConnectionString;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (testConnection)
|
||||
{
|
||||
Console.WriteLine("Testing connection...");
|
||||
testConnection.Open();
|
||||
Console.WriteLine("Connection successful!");
|
||||
|
||||
if (databaseConfiguration.DatabaseType != DatabaseType.SqlServer)
|
||||
{
|
||||
Console.WriteLine("Checking MySQL/MariaDB version...");
|
||||
using (var command = testConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT VERSION()";
|
||||
var fullVersion = (string)command.ExecuteScalar();
|
||||
Console.WriteLine(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion));
|
||||
var splits = fullVersion.Split('-');
|
||||
databaseConfiguration.MySqlServerVersion = splits[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbExists)
|
||||
{
|
||||
Console.WriteLine("Testing create DB permission...");
|
||||
using (var command = testConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = String.Format(CultureInfo.InvariantCulture, "CREATE DATABASE {0}", databaseName);
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
Console.WriteLine("Success!");
|
||||
Console.WriteLine("Dropping test database...");
|
||||
using (var command = testConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = String.Format(CultureInfo.InvariantCulture, "DROP DATABASE {0}", databaseName);
|
||||
try
|
||||
{
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e.Message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("This should be okay, but you may want to manually drop the database before continuing!");
|
||||
Console.WriteLine("Press any key to continue...");
|
||||
Console.ReadKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e.Message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Retrying database configuration...");
|
||||
}
|
||||
} while (true);
|
||||
|
||||
var generalConfiguration = new GeneralConfiguration
|
||||
{
|
||||
MinimumPasswordLength = 15
|
||||
};
|
||||
do
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.Write("Minimum database user password length (leave blank for default of 15): ");
|
||||
var passwordLengthString = Console.ReadLine();
|
||||
if (String.IsNullOrWhiteSpace(passwordLengthString))
|
||||
break;
|
||||
if (UInt32.TryParse(passwordLengthString, out var passwordLength))
|
||||
{
|
||||
generalConfiguration.MinimumPasswordLength = passwordLength;
|
||||
break;
|
||||
}
|
||||
Console.WriteLine("Please enter a positive integer!");
|
||||
}
|
||||
while (true);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter a GitHub personal access token to bypass some rate limits (this is optional and does not require any scopes)");
|
||||
Console.Write("GitHub personal access token: ");
|
||||
generalConfiguration.GitHubAccessToken = GetPassword();
|
||||
if (String.IsNullOrWhiteSpace(generalConfiguration.GitHubAccessToken))
|
||||
generalConfiguration.GitHubAccessToken = null;
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName));
|
||||
|
||||
var map = new Dictionary<string, object>()
|
||||
{
|
||||
{ DatabaseConfiguration.Section, databaseConfiguration },
|
||||
{ GeneralConfiguration.Section, generalConfiguration }
|
||||
};
|
||||
|
||||
if (port.HasValue)
|
||||
map.Add("Kestrel", new {
|
||||
EndPoints = new
|
||||
{
|
||||
Http = new
|
||||
{
|
||||
Url = String.Format(CultureInfo.InvariantCulture, "http://0.0.0.0:{0}", port)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var json = JsonConvert.SerializeObject(map, Formatting.Indented);
|
||||
var configBytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var writeTask = ioManager.WriteAllBytes(userConfigFileName, configBytes, default);
|
||||
try
|
||||
{
|
||||
writeTask.GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e.Message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("For your convienence, here's the text we tried to write out:");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(json);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press any key to exit...");
|
||||
Console.ReadKey();
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
|
||||
Console.WriteLine("Waiting for configuration changes to reload...");
|
||||
Task.Delay(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure dependency injected services
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to configure</param>
|
||||
/// <param name="applicationLifetime">The <see cref="Microsoft.AspNetCore.Hosting.IApplicationLifetime"/> representing the lifetime of the <see cref="Application"/></param>
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
if (services == null)
|
||||
throw new ArgumentNullException(nameof(services));
|
||||
|
||||
//needful
|
||||
services.AddSingleton<IApplication>(this);
|
||||
|
||||
//configure configuration
|
||||
services.Configure<UpdatesConfiguration>(configuration.GetSection(UpdatesConfiguration.Section));
|
||||
services.Configure<DatabaseConfiguration>(configuration.GetSection(DatabaseConfiguration.Section));
|
||||
services.Configure<GeneralConfiguration>(configuration.GetSection(GeneralConfiguration.Section));
|
||||
services.Configure<FileLoggingConfiguration>(configuration.GetSection(FileLoggingConfiguration.Section));
|
||||
|
||||
//enable options which give us config reloading
|
||||
services.AddOptions();
|
||||
|
||||
//need this instance for later configuration
|
||||
var ioManager = new DefaultIOManager();
|
||||
var ranWizard = CheckRunSetupWizard(ioManager);
|
||||
|
||||
//setup stuff for setup wizard
|
||||
services.AddSingleton<IIOManager>(ioManager);
|
||||
services.AddSingleton<IConsole, IO.Console>();
|
||||
services.AddSingleton<ISetupWizard, SetupWizard>();
|
||||
|
||||
//needed here for JWT configuration
|
||||
services.AddSingleton<ITokenFactory, TokenFactory>();
|
||||
|
||||
GeneralConfiguration generalConfiguration;
|
||||
DatabaseConfiguration databaseConfiguration;
|
||||
FileLoggingConfiguration fileLoggingConfiguration;
|
||||
ITokenFactory tokenFactory;
|
||||
|
||||
//temporarily build the service provider in it's current state
|
||||
//do it here so we can run the setup wizard if necessary
|
||||
//also allows us to get some options and other services we need for continued configuration
|
||||
using (var provider = services.BuildServiceProvider())
|
||||
{
|
||||
//run the wizard if necessary
|
||||
var setupWizard = provider.GetRequiredService<ISetupWizard>();
|
||||
var applicationLifetime = provider.GetRequiredService<Microsoft.AspNetCore.Hosting.IApplicationLifetime>();
|
||||
var setupWizardRan = setupWizard.CheckRunWizard(applicationLifetime.ApplicationStopping).GetAwaiter().GetResult();
|
||||
|
||||
//load the configuration options we need
|
||||
var generalOptions = provider.GetRequiredService<IOptions<GeneralConfiguration>>();
|
||||
generalConfiguration = generalOptions.Value;
|
||||
|
||||
if (generalConfiguration.ConfigCheckOnly)
|
||||
throw new OperationCanceledException("Configuration check complete!");
|
||||
|
||||
if (ranWizard)
|
||||
Console.WriteLine("Now launching TGS...");
|
||||
//unless this is set, in which case, we leave
|
||||
if (setupWizardRan && generalConfiguration.SetupWizardMode == SetupWizardMode.Only)
|
||||
//we don't inject a logger in the constuctor to log this because it's not yet configured
|
||||
throw new OperationCanceledException("Exiting due to SetupWizardMode configuration!");
|
||||
|
||||
var dbOptions = provider.GetRequiredService<IOptions<DatabaseConfiguration>>();
|
||||
databaseConfiguration = dbOptions.Value;
|
||||
|
||||
var loggingOptions = provider.GetRequiredService<IOptions<FileLoggingConfiguration>>();
|
||||
fileLoggingConfiguration = loggingOptions.Value;
|
||||
|
||||
tokenFactory = provider.GetRequiredService<ITokenFactory>();
|
||||
}
|
||||
|
||||
|
||||
//setup file logging via serilog
|
||||
if (!fileLoggingConfiguration.Disable)
|
||||
{
|
||||
var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs");
|
||||
|
||||
logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log");
|
||||
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
//common app data is C:/ProgramData on windows, else /usr/shar
|
||||
var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs");
|
||||
|
||||
logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log");
|
||||
|
||||
LogEventLevel? ConvertLogLevel(LogLevel logLevel)
|
||||
{
|
||||
switch (logLevel)
|
||||
@@ -499,44 +191,21 @@ namespace Tgstation.Server.Host.Core
|
||||
|
||||
builder.AddSerilog(configuration.CreateLogger(), true);
|
||||
});
|
||||
}
|
||||
|
||||
services.AddScoped<IClaimsInjector, ClaimsInjector>();
|
||||
|
||||
const string scheme = "JwtBearer";
|
||||
services.AddAuthentication((options) =>
|
||||
//configure bearer token validation
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, jwtBearerOptions =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = scheme;
|
||||
options.DefaultChallengeScheme = scheme;
|
||||
}).AddJwtBearer(scheme, jwtBearerOptions =>
|
||||
{
|
||||
jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(TokenFactory.TokenSigningKey),
|
||||
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = TokenFactory.TokenIssuer,
|
||||
|
||||
ValidateLifetime = true,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = TokenFactory.TokenAudience,
|
||||
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
|
||||
RequireSignedTokens = true,
|
||||
|
||||
RequireExpirationTime = true
|
||||
};
|
||||
jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters;
|
||||
jwtBearerOptions.Events = new JwtBearerEvents
|
||||
{
|
||||
//Application is our composition root so this monstrosity of a line is okay
|
||||
OnTokenValidated = ctx => ctx.HttpContext.RequestServices.GetRequiredService<IClaimsInjector>().InjectClaimsIntoContext(ctx, ctx.HttpContext.RequestAborted)
|
||||
};
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs
|
||||
});
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //fucking converts 'sub' to M$ bs
|
||||
|
||||
//add mvc, configure the json serializer settings
|
||||
services.AddMvc().AddJsonOptions(options =>
|
||||
{
|
||||
options.AllowInputFormatterExceptionMessages = true;
|
||||
@@ -547,7 +216,6 @@ namespace Tgstation.Server.Host.Core
|
||||
options.SerializerSettings.Converters = new[] { new VersionConverter() };
|
||||
});
|
||||
|
||||
|
||||
void AddTypedContext<TContext>() where TContext : DatabaseContext<TContext>
|
||||
{
|
||||
services.AddDbContext<TContext>(builder =>
|
||||
@@ -558,6 +226,7 @@ namespace Tgstation.Server.Host.Core
|
||||
services.AddScoped<IDatabaseContext>(x => x.GetRequiredService<TContext>());
|
||||
}
|
||||
|
||||
//add the correct database context type
|
||||
var dbType = databaseConfiguration.DatabaseType;
|
||||
switch (dbType)
|
||||
{
|
||||
@@ -572,18 +241,18 @@ namespace Tgstation.Server.Host.Core
|
||||
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid {0}: {1}!", nameof(DatabaseType), dbType));
|
||||
}
|
||||
|
||||
services.AddScoped<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>();
|
||||
|
||||
//configure platform specific services
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
services.AddSingleton<ISystemIdentityFactory, WindowsSystemIdentityFactory>();
|
||||
@@ -604,30 +273,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>
|
||||
@@ -650,21 +318,31 @@ namespace Tgstation.Server.Host.Core
|
||||
//attempt to restart the server if the configuration changes
|
||||
ChangeToken.OnChange(configuration.GetReloadToken, () => serverControl.Restart());
|
||||
|
||||
//now setup the HTTP request pipeline
|
||||
|
||||
//should anything after this throw an exception, catch it and display a detailed html page
|
||||
applicationBuilder.UseDeveloperExceptionPage(); //it is not worth it to limit this, you should only ever get it if you're an authorized user
|
||||
|
||||
//suppress OperationCancelledExceptions, they are just aborted HTTP requests
|
||||
applicationBuilder.UseCancelledRequestSuppression();
|
||||
|
||||
//Do not service requests until Ready is called, this will return 503 until that point
|
||||
applicationBuilder.UseAsyncInitialization(async cancellationToken =>
|
||||
{
|
||||
using (cancellationToken.Register(() => startupTcs.SetCanceled()))
|
||||
await startupTcs.Task.ConfigureAwait(false);
|
||||
});
|
||||
|
||||
//authenticate JWT tokens using our security pipeline if present, returns 401 if bad
|
||||
applicationBuilder.UseAuthentication();
|
||||
|
||||
//suppress and log database exceptions
|
||||
applicationBuilder.UseDbConflictHandling();
|
||||
|
||||
//majority of handling is done in the controllers
|
||||
applicationBuilder.UseMvc();
|
||||
|
||||
//404 anything that gets this far
|
||||
}
|
||||
|
||||
///<inheritdoc />
|
||||
|
||||
@@ -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,471 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MySql.Data.MySqlClient;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.Data.SqlClient;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Tgstation.Server.Host.Configuration;
|
||||
using Tgstation.Server.Host.IO;
|
||||
|
||||
namespace Tgstation.Server.Host.Core
|
||||
{
|
||||
/// <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="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="hostingEnvironment">The value of <see cref="hostingEnvironment"/></param>
|
||||
/// <param name="application">The value of <see cref="application"/></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, 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.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);
|
||||
var databaseName = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
bool dbExists;
|
||||
do
|
||||
{
|
||||
await console.WriteAsync("Does this database already exist? (y/n): ", false, cancellationToken).ConfigureAwait(false);
|
||||
var responseString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
var upperResponse = responseString.ToUpperInvariant();
|
||||
if (upperResponse == "Y" || upperResponse == "YES")
|
||||
{
|
||||
dbExists = true;
|
||||
break;
|
||||
}
|
||||
else if (upperResponse == "N" || upperResponse == "NO")
|
||||
{
|
||||
dbExists = false;
|
||||
break;
|
||||
}
|
||||
await console.WriteAsync("Invalid response!", true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
while (true);
|
||||
|
||||
bool? useWinAuth;
|
||||
if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
useWinAuth = await PromptYesNo("Use Windows Authentication? (y/n): ", cancellationToken).ConfigureAwait(false);
|
||||
else
|
||||
useWinAuth = null;
|
||||
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string username = null;
|
||||
string password = null;
|
||||
if (useWinAuth != true)
|
||||
{
|
||||
await console.WriteAsync("Enter username: ", false, cancellationToken).ConfigureAwait(false);
|
||||
username = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Enter password: ", false, cancellationToken).ConfigureAwait(false);
|
||||
password = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
DbConnection testConnection;
|
||||
if (databaseConfiguration.DatabaseType == DatabaseType.SqlServer)
|
||||
{
|
||||
var csb = new SqlConnectionStringBuilder
|
||||
{
|
||||
ApplicationName = application.VersionPrefix,
|
||||
DataSource = serverAddress ?? "(local)"
|
||||
};
|
||||
if (useWinAuth.Value)
|
||||
csb.IntegratedSecurity = true;
|
||||
else
|
||||
{
|
||||
csb.UserID = username;
|
||||
csb.Password = password;
|
||||
}
|
||||
testConnection = new SqlConnection
|
||||
{
|
||||
ConnectionString = csb.ConnectionString
|
||||
};
|
||||
|
||||
csb.InitialCatalog = databaseName;
|
||||
databaseConfiguration.ConnectionString = csb.ConnectionString;
|
||||
}
|
||||
else
|
||||
{
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = serverAddress ?? "127.0.0.1",
|
||||
UserID = username,
|
||||
Password = password
|
||||
};
|
||||
testConnection = new MySqlConnection
|
||||
{
|
||||
ConnectionString = csb.ConnectionString
|
||||
};
|
||||
csb.Database = databaseName;
|
||||
databaseConfiguration.ConnectionString = csb.ConnectionString;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (testConnection)
|
||||
{
|
||||
await console.WriteAsync("Testing connection...", true, cancellationToken).ConfigureAwait(false);
|
||||
await testConnection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Connection successful!", true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (databaseConfiguration.DatabaseType != DatabaseType.SqlServer)
|
||||
{
|
||||
await console.WriteAsync("Checking MySQL/MariaDB version...", true, cancellationToken).ConfigureAwait(false);
|
||||
using (var command = testConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT VERSION()";
|
||||
var fullVersion = (string)(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Found {0}", fullVersion), true, cancellationToken).ConfigureAwait(false);
|
||||
var splits = fullVersion.Split('-');
|
||||
databaseConfiguration.MySqlServerVersion = splits[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbExists)
|
||||
{
|
||||
await console.WriteAsync("Testing create DB permission...", true, cancellationToken).ConfigureAwait(false);
|
||||
using (var command = testConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = String.Format(CultureInfo.InvariantCulture, "CREATE DATABASE {0}", databaseName);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await console.WriteAsync("Success!", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Dropping test database...", true, cancellationToken).ConfigureAwait(false);
|
||||
using (var command = testConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = String.Format(CultureInfo.InvariantCulture, "DROP DATABASE {0}", databaseName);
|
||||
try
|
||||
{
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("This should be okay, but you may want to manually drop the database before continuing!", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Press any key to continue...", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return databaseConfiguration;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Retrying database configuration...", true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
} while (true);
|
||||
}
|
||||
|
||||
/// <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 generalConfiguration = new GeneralConfiguration();
|
||||
do
|
||||
{
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Minimum database user password length (leave blank for default of {0}): ", generalConfiguration.MinimumPasswordLength), false, cancellationToken).ConfigureAwait(false);
|
||||
var passwordLengthString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
if (String.IsNullOrWhiteSpace(passwordLengthString))
|
||||
break;
|
||||
if (UInt32.TryParse(passwordLengthString, out var passwordLength) && passwordLength >= 0)
|
||||
{
|
||||
generalConfiguration.MinimumPasswordLength = passwordLength;
|
||||
break;
|
||||
}
|
||||
await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
while (true);
|
||||
|
||||
do
|
||||
{
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Timeout for sending and receiving BYOND topics (ms, 0 for infinite, leave blank for default of {0}): ", generalConfiguration.ByondTopicTimeout), false, cancellationToken).ConfigureAwait(false);
|
||||
var topicTimeoutString = await console.ReadLineAsync(false, cancellationToken).ConfigureAwait(false);
|
||||
if (String.IsNullOrWhiteSpace(topicTimeoutString))
|
||||
break;
|
||||
if (Int32.TryParse(topicTimeoutString, out var topicTimeout) && topicTimeout >= 0)
|
||||
{
|
||||
generalConfiguration.ByondTopicTimeout = topicTimeout;
|
||||
break;
|
||||
}
|
||||
await console.WriteAsync("Please enter a positive integer!", true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
while (true);
|
||||
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Enter a GitHub personal access token to bypass some rate limits (this is optional and does not require any scopes)", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("GitHub personal access token: ", false, cancellationToken).ConfigureAwait(false);
|
||||
generalConfiguration.GitHubAccessToken = await console.ReadLineAsync(true, cancellationToken).ConfigureAwait(false);
|
||||
if (String.IsNullOrWhiteSpace(generalConfiguration.GitHubAccessToken))
|
||||
generalConfiguration.GitHubAccessToken = null;
|
||||
return generalConfiguration;
|
||||
}
|
||||
|
||||
async Task SaveConfiguration(string userConfigFileName, ushort? hostingPort, DatabaseConfiguration databaseConfiguration, GeneralConfiguration generalConfiguration, CancellationToken cancellationToken)
|
||||
{
|
||||
await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "Configuration complete! Saving to {0}", userConfigFileName), true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var map = new Dictionary<string, object>()
|
||||
{
|
||||
{ DatabaseConfiguration.Section, databaseConfiguration },
|
||||
{ GeneralConfiguration.Section, generalConfiguration }
|
||||
};
|
||||
|
||||
if (hostingPort.HasValue)
|
||||
map.Add("Kestrel", new
|
||||
{
|
||||
EndPoints = new
|
||||
{
|
||||
Http = new
|
||||
{
|
||||
Url = String.Format(CultureInfo.InvariantCulture, "http://0.0.0.0:{0}", hostingPort)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var json = JsonConvert.SerializeObject(map, Formatting.Indented);
|
||||
var configBytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
try
|
||||
{
|
||||
await ioManager.WriteAllBytes(userConfigFileName, configBytes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await console.WriteAsync(e.Message, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("For your convienence, here's the json we tried to write out:", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(json, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("Press any key to exit...", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.PressAnyKeyAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new OperationCanceledException();
|
||||
}
|
||||
|
||||
await console.WriteAsync("Waiting for configuration changes to reload...", true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
//we need to wait for the configuration's file system watcher to read and reload the changes
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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></returns>
|
||||
async Task RunWizard(string userConfigFileName, CancellationToken cancellationToken)
|
||||
{
|
||||
//welcome message
|
||||
await console.WriteAsync("Welcome to tgstation-server 4!", true, cancellationToken).ConfigureAwait(false);
|
||||
await console.WriteAsync("This wizard will help you configure your server.", true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var hostingPort = await PromptForHostingPort(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var databaseConfiguration = await ConfigureDatabase(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var generalConfiguration = await ConfigureGeneral(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await console.WriteAsync(null, true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await SaveConfiguration(userConfigFileName, hostingPort, databaseConfiguration, generalConfiguration, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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 existenceTask = ioManager.FileExists(userConfigFileName, default);
|
||||
var exists = existenceTask.GetAwaiter().GetResult();
|
||||
|
||||
bool shouldRunBasedOnAutodetect;
|
||||
if (exists)
|
||||
{
|
||||
var readTask = ioManager.ReadAllBytes(userConfigFileName, default);
|
||||
var bytes = readTask.GetAwaiter().GetResult();
|
||||
var contents = Encoding.UTF8.GetString(bytes);
|
||||
var existingConfigIsEmpty = String.IsNullOrWhiteSpace(contents);
|
||||
logger.LogTrace("Configuration json detected. Empty: {0}", existingConfigIsEmpty);
|
||||
shouldRunBasedOnAutodetect = existingConfigIsEmpty;
|
||||
}
|
||||
else
|
||||
{
|
||||
shouldRunBasedOnAutodetect = true;
|
||||
logger.LogTrace("No configuration json detected");
|
||||
}
|
||||
|
||||
|
||||
if (!shouldRunBasedOnAutodetect)
|
||||
{
|
||||
if (forceRun)
|
||||
{
|
||||
logger.LogTrace("Asking user to bypass due to force run request...");
|
||||
await console.WriteAsync(String.Format(CultureInfo.InvariantCulture, "The configuration settings are requesting the setup wizard be run, but you already appear to have a configuration file ({0})!", userConfigFileName), true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
forceRun = await PromptYesNo("Continue running setup wizard? (y/n): ", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
if (!forceRun)
|
||||
return false;
|
||||
}
|
||||
|
||||
await RunWizard(userConfigFileName, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PressAnyKeyAsync(CancellationToken cancellationToken) => Task.Factory.StartNew(() => System.Console.ReadKey(), 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
|
||||
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(() =>
|
||||
{
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"General": {
|
||||
"MinimumPasswordLength": 15,
|
||||
"GitHubAccessToken": null,
|
||||
"ConfigCheckOnly": false
|
||||
"SetupWizardMode": "AutoDetect",
|
||||
"ByondTopicTimeout": 5000
|
||||
},
|
||||
"FileLogging": {
|
||||
"Directory": null, //use the default path
|
||||
|
||||
Reference in New Issue
Block a user