tgstation-server
The /tg/station 13 server suite
Application.cs
Go to the documentation of this file.
1 using Byond.TopicSender;
2 using Cyberboss.AspNetCore.AsyncInitializer;
3 using Microsoft.AspNetCore.Authentication.JwtBearer;
4 using Microsoft.AspNetCore.Builder;
5 using Microsoft.AspNetCore.Cors.Infrastructure;
6 using Microsoft.AspNetCore.Hosting;
7 using Microsoft.AspNetCore.Identity;
8 using Microsoft.Extensions.Configuration;
9 using Microsoft.Extensions.DependencyInjection;
10 using Microsoft.Extensions.Hosting;
11 using Microsoft.Extensions.Logging;
12 using Microsoft.Extensions.Options;
13 using Microsoft.Extensions.Primitives;
14 using Newtonsoft.Json;
15 using Newtonsoft.Json.Converters;
16 using Serilog;
17 using Serilog.Events;
18 using Serilog.Formatting.Display;
19 using System;
20 using System.Globalization;
21 using System.IdentityModel.Tokens.Jwt;
22 using System.Reflection;
23 using System.Threading.Tasks;
30 using Tgstation.Server.Host.IO;
33 
34 namespace Tgstation.Server.Host.Core
35 {
37  sealed class Application : IApplication
38  {
40  public string VersionPrefix => "tgstation-server";
41 
43  public Version Version { get; }
44 
46  public string VersionString { get; }
47 
51  readonly IConfiguration configuration;
52 
56  readonly Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment;
57 
61  readonly TaskCompletionSource<object> startupTcs;
62 
67 
73  public Application(IConfiguration configuration, Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment)
74  {
75  this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
76  this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment));
77 
78  startupTcs = new TaskCompletionSource<object>();
79 
80  Version = Assembly.GetExecutingAssembly().GetName().Version;
81  VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version);
82  }
83 
88  public void ConfigureServices(IServiceCollection services)
89  {
90  if (services == null)
91  throw new ArgumentNullException(nameof(services));
92 
93  //needful
94  services.AddSingleton<IApplication>(this);
95 
96  //configure configuration
97  services.UseStandardConfig<UpdatesConfiguration>(configuration);
98  services.UseStandardConfig<DatabaseConfiguration>(configuration);
99  services.UseStandardConfig<GeneralConfiguration>(configuration);
100  services.UseStandardConfig<FileLoggingConfiguration>(configuration);
101  services.UseStandardConfig<ControlPanelConfiguration>(configuration);
102 
103  //enable options which give us config reloading
104  services.AddOptions();
105 
106  //other stuff needed for for setup wizard and configuration
107  services.AddSingleton<IIOManager, DefaultIOManager>();
108  services.AddSingleton<IConsole, IO.Console>();
109  services.AddSingleton<IDBConnectionFactory, DBConnectionFactory>();
110  services.AddSingleton<ISetupWizard, SetupWizard>();
111  services.AddSingleton<IPlatformIdentifier, PlatformIdentifier>();
112  services.AddSingleton<IAsyncDelayer, AsyncDelayer>();
113 
114  GeneralConfiguration generalConfiguration;
115  DatabaseConfiguration databaseConfiguration;
116  FileLoggingConfiguration fileLoggingConfiguration;
117  ControlPanelConfiguration controlPanelConfiguration;
118  IIOManager ioManager;
119  IPlatformIdentifier platformIdentifier;
120 
121  //temporarily build the service provider in it's current state
122  //do it here so we can run the setup wizard if necessary
123  //also allows us to get some options and other services we need for continued configuration
124  using (var provider = services.BuildServiceProvider())
125  {
126  //run the wizard if necessary
127  var setupWizard = provider.GetRequiredService<ISetupWizard>();
128  var applicationLifetime = provider.GetRequiredService<Microsoft.AspNetCore.Hosting.IApplicationLifetime>();
129  var setupWizardRan = setupWizard.CheckRunWizard(applicationLifetime.ApplicationStopping).GetAwaiter().GetResult();
130 
131  //load the configuration options we need
132  var generalOptions = provider.GetRequiredService<IOptions<GeneralConfiguration>>();
133  generalConfiguration = generalOptions.Value;
134 
135  //unless this is set, in which case, we leave
136  if (setupWizardRan && generalConfiguration.SetupWizardMode == SetupWizardMode.Only)
137  //we don't inject a logger in the constuctor to log this because it's not yet configured
138  throw new OperationCanceledException("Exiting due to SetupWizardMode configuration!");
139 
140  var dbOptions = provider.GetRequiredService<IOptions<DatabaseConfiguration>>();
141  databaseConfiguration = dbOptions.Value;
142 
143  var loggingOptions = provider.GetRequiredService<IOptions<FileLoggingConfiguration>>();
144  fileLoggingConfiguration = loggingOptions.Value;
145 
146  var controlPanelOptions = provider.GetRequiredService<IOptions<ControlPanelConfiguration>>();
147  controlPanelConfiguration = controlPanelOptions.Value;
148 
149  ioManager = provider.GetRequiredService<IIOManager>();
150  platformIdentifier = provider.GetRequiredService<IPlatformIdentifier>();
151  }
152 
153  //setup file logging via serilog
154  if (!fileLoggingConfiguration.Disable)
155  services.AddLogging(builder =>
156  {
157  //common app data is C:/ProgramData on windows, else /usr/shar
158  var logPath = !String.IsNullOrEmpty(fileLoggingConfiguration.Directory) ? fileLoggingConfiguration.Directory : ioManager.ConcatPath(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), VersionPrefix, "Logs");
159 
160  logPath = ioManager.ConcatPath(logPath, "tgs-{Date}.log");
161 
162  LogEventLevel? ConvertLogLevel(LogLevel logLevel)
163  {
164  switch (logLevel)
165  {
166  case LogLevel.Critical:
167  return LogEventLevel.Fatal;
168  case LogLevel.Debug:
169  return LogEventLevel.Debug;
170  case LogLevel.Error:
171  return LogEventLevel.Error;
172  case LogLevel.Information:
173  return LogEventLevel.Information;
174  case LogLevel.Trace:
175  return LogEventLevel.Verbose;
176  case LogLevel.Warning:
177  return LogEventLevel.Warning;
178  case LogLevel.None:
179  return null;
180  default:
181  throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid log level {0}", logLevel));
182  }
183  };
184 
185  var logEventLevel = ConvertLogLevel(fileLoggingConfiguration.LogLevel);
186  var microsoftEventLevel = ConvertLogLevel(fileLoggingConfiguration.MicrosoftLogLevel);
187 
188  var formatter = new MessageTemplateTextFormatter("{Timestamp:o} {RequestId,13} [{Level:u3}] {SourceContext:l}: {Message} ({EventId:x8}){NewLine}{Exception}", null);
189 
190  var configuration = new LoggerConfiguration()
191  .Enrich.FromLogContext()
192  .WriteTo.Async(w => w.RollingFile(formatter, logPath, shared: true, flushToDiskInterval: TimeSpan.FromSeconds(2)));
193 
194  if (logEventLevel.HasValue)
195  configuration.MinimumLevel.Is(logEventLevel.Value);
196 
197  if (microsoftEventLevel.HasValue)
198  configuration.MinimumLevel.Override("Microsoft", microsoftEventLevel.Value);
199 
200  builder.AddSerilog(configuration.CreateLogger(), true);
201  });
202 
203  //configure bearer token validation
204  services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(jwtBearerOptions =>
205  {
206  //this line isn't actually run until the first request is made
207  //at that point tokenFactory will be populated
208  jwtBearerOptions.TokenValidationParameters = tokenFactory.ValidationParameters;
209  jwtBearerOptions.Events = new JwtBearerEvents
210  {
211  //Application is our composition root so this monstrosity of a line is okay
212  OnTokenValidated = ctx => ctx.HttpContext.RequestServices.GetRequiredService<IClaimsInjector>().InjectClaimsIntoContext(ctx, ctx.HttpContext.RequestAborted)
213  };
214  });
215  //fucking converts 'sub' to M$ bs
216  //can't be done in the above lambda, that's too late
217  JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
218 
219  //add mvc, configure the json serializer settings
220  services.AddMvc().AddJsonOptions(options =>
221  {
222  options.AllowInputFormatterExceptionMessages = true;
223  options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
224  options.SerializerSettings.CheckAdditionalContent = true;
225  options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error;
226  options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
227  options.SerializerSettings.Converters = new[] { new VersionConverter() };
228  });
229 
230  //enable browser detection
231  services.AddDetectionCore().AddBrowser();
232 
233  //enable CORS if necessary
234  if (controlPanelConfiguration.AllowAnyOrigin || controlPanelConfiguration.AllowedOrigins?.Count > 0)
235  services.AddCors();
236 
237  void AddTypedContext<TContext>() where TContext : DatabaseContext<TContext>
238  {
239  services.AddDbContext<TContext>(builder =>
240  {
241  if (hostingEnvironment.IsDevelopment())
242  builder.EnableSensitiveDataLogging();
243  });
244  services.AddScoped<IDatabaseContext>(x => x.GetRequiredService<TContext>());
245  }
246 
247  //add the correct database context type
248  var dbType = databaseConfiguration.DatabaseType;
249  switch (dbType)
250  {
251  case DatabaseType.MySql:
252  case DatabaseType.MariaDB:
253  AddTypedContext<MySqlDatabaseContext>();
254  break;
255  case DatabaseType.SqlServer:
256  AddTypedContext<SqlServerDatabaseContext>();
257  break;
258  default:
259  throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid {0}: {1}!", nameof(DatabaseType), dbType));
260  }
261  //configure other database services
262  services.AddSingleton<IDatabaseContextFactory, DatabaseContextFactory>();
263  services.AddSingleton<IDatabaseSeeder, DatabaseSeeder>();
264 
265  //configure security services
267  services.AddScoped<IClaimsInjector, ClaimsInjector>();
268  services.AddSingleton<IIdentityCache, IdentityCache>();
269  services.AddSingleton<ICryptographySuite, CryptographySuite>();
270  services.AddSingleton<ITokenFactory, TokenFactory>();
271  services.AddSingleton<IPasswordHasher<Models.User>, PasswordHasher<Models.User>>();
272 
273  //configure platform specific services
274  if (platformIdentifier.IsWindows)
275  {
276  services.AddSingleton<ISystemIdentityFactory, WindowsSystemIdentityFactory>();
277  services.AddSingleton<ISymlinkFactory, WindowsSymlinkFactory>();
278  services.AddSingleton<IByondInstaller, WindowsByondInstaller>();
279  services.AddSingleton<IPostWriteHandler, WindowsPostWriteHandler>();
280 
281  services.AddSingleton<WindowsNetworkPromptReaper>();
282  services.AddSingleton<INetworkPromptReaper>(x => x.GetRequiredService<WindowsNetworkPromptReaper>());
283  services.AddSingleton<IHostedService>(x => x.GetRequiredService<WindowsNetworkPromptReaper>());
284  }
285  else
286  {
287  services.AddSingleton<ISystemIdentityFactory, PosixSystemIdentityFactory>();
288  services.AddSingleton<ISymlinkFactory, PosixSymlinkFactory>();
289  services.AddSingleton<IByondInstaller, PosixByondInstaller>();
290  services.AddSingleton<IPostWriteHandler, PosixPostWriteHandler>();
291  services.AddSingleton<INetworkPromptReaper, PosixNetworkPromptReaper>();
292  }
293 
294  //configure misc services
295  services.AddSingleton<ISynchronousIOManager, SynchronousIOManager>();
296  services.AddSingleton<IGitHubClientFactory, GitHubClientFactory>();
297  services.AddSingleton<IProcessExecutor, ProcessExecutor>();
298  services.AddSingleton<IByondTopicSender>(new ByondTopicSender
299  {
300  ReceiveTimeout = generalConfiguration.ByondTopicTimeout,
301  SendTimeout = generalConfiguration.ByondTopicTimeout
302  });
303 
304  //configure component services
305  services.AddSingleton<ICredentialsProvider, CredentialsProvider>();
306  services.AddSingleton<IProviderFactory, ProviderFactory>();
307  services.AddSingleton<IChatFactory, ChatFactory>();
308  services.AddSingleton<IWatchdogFactory, WatchdogFactory>();
309  services.AddSingleton<IInstanceFactory, InstanceFactory>();
310 
311  //configure root services
312  services.AddSingleton<InstanceManager>();
313  services.AddSingleton<IInstanceManager>(x => x.GetRequiredService<InstanceManager>());
314  services.AddSingleton<IHostedService>(x => x.GetRequiredService<InstanceManager>());
315 
316  services.AddSingleton<IJobManager, JobManager>();
317  }
318 
327  public void Configure(IApplicationBuilder applicationBuilder, IServerControl serverControl, ITokenFactory tokenFactory, IOptions<ControlPanelConfiguration> controlPanelConfigurationOptions, ILogger<Application> logger)
328  {
329  if (applicationBuilder == null)
330  throw new ArgumentNullException(nameof(applicationBuilder));
331  if (serverControl == null)
332  throw new ArgumentNullException(nameof(serverControl));
333 
334  this.tokenFactory = tokenFactory ?? throw new ArgumentNullException(nameof(tokenFactory));
335 
336  var controlPanelConfiguration = controlPanelConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(controlPanelConfigurationOptions));
337 
338  if (logger == null)
339  throw new ArgumentNullException(nameof(logger));
340 
341  logger.LogInformation(VersionString);
342  logger.LogDebug("Content Root: {0}", hostingEnvironment.ContentRootPath);
343  logger.LogTrace("Web Root: {0}", hostingEnvironment.WebRootPath);
344 
345  //attempt to restart the server if the configuration changes
346  if(serverControl.WatchdogPresent)
347  ChangeToken.OnChange(configuration.GetReloadToken, () => serverControl.Restart());
348 
349  //now setup the HTTP request pipeline
350 
351  //should anything after this throw an exception, catch it and display a detailed html page
352  applicationBuilder.UseDeveloperExceptionPage(); //it is not worth it to limit this, you should only ever get it if you're an authorized user
353 
354  //suppress OperationCancelledExceptions, they are just aborted HTTP requests
355  applicationBuilder.UseCancelledRequestSuppression();
356 
357  //Set up CORS based on configuration if necessary
358  Action<CorsPolicyBuilder> corsBuilder = null;
359  if (controlPanelConfiguration.AllowAnyOrigin)
360  {
361  logger.LogTrace("Access-Control-Allow-Origin: *");
362  corsBuilder = builder => builder.AllowAnyOrigin();
363  }
364  else if (controlPanelConfiguration.AllowedOrigins?.Count > 0)
365  {
366  logger.LogTrace("Access-Control-Allow-Origin: ", String.Join(',', controlPanelConfiguration.AllowedOrigins));
367  corsBuilder = builder => builder.WithOrigins(controlPanelConfiguration.AllowedOrigins.ToArray());
368  }
369 
370  if (corsBuilder != null)
371  {
372  var originalBuilder = corsBuilder;
373  corsBuilder = builder => originalBuilder(builder.AllowAnyHeader().AllowAnyMethod());
374  applicationBuilder.UseCors(corsBuilder);
375  }
376 
377  //Do not service requests until Ready is called, this will return 503 until that point
378  applicationBuilder.UseAsyncInitialization(async cancellationToken =>
379  {
380  using (cancellationToken.Register(() => startupTcs.SetCanceled()))
381  await startupTcs.Task.ConfigureAwait(false);
382  });
383 
384  //spa loading if necessary
385  if (controlPanelConfiguration.Enable)
386  {
387  logger.LogWarning("Web control panel enabled. This is a highly WIP feature!");
388  applicationBuilder.UseStaticFiles();
389  }
390  else
391  logger.LogDebug("Web control panel disabled!");
392 
393  //authenticate JWT tokens using our security pipeline if present, returns 401 if bad
394  applicationBuilder.UseAuthentication();
395 
396  //suppress and log database exceptions
397  applicationBuilder.UseDbConflictHandling();
398 
399  //majority of handling is done in the controllers
400  applicationBuilder.UseMvc();
401 
402  //404 anything that gets this far
403  }
404 
406  public void Ready(Exception initializationError)
407  {
408  lock (startupTcs)
409  {
410  if (startupTcs.Task.IsCompleted)
411  throw new InvalidOperationException("Ready has already been called!");
412  if (initializationError == null)
413  startupTcs.SetResult(null);
414  else
415  startupTcs.SetException(initializationError);
416  }
417  }
418  }
419 }
LogLevel MicrosoftLogLevel
The minimum Microsoft.Extensions.Logging.LogLevel to display in logs for Microsoft library sources ...
Manages the runtime of Jobs
Definition: IJobManager.cs:12
readonly Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment
The Microsoft.AspNetCore.Hosting.IHostingEnvironment for the Application
Definition: Application.cs:56
IPostWriteHandler for POSIX systems
List< string > AllowedOrigins
Origins allowed for CORS requests
void Configure(IApplicationBuilder applicationBuilder, IServerControl serverControl, ITokenFactory tokenFactory, IOptions< ControlPanelConfiguration > controlPanelConfigurationOptions, ILogger< Application > logger)
Configure the Application
Definition: Application.cs:327
Configures the ASP.NET Core web application
Definition: IApplication.cs:8
SetupWizardMode
Determines if the Core.ISetupWizard will run
Abstraction for System.Console
Definition: IConsole.cs:9
bool WatchdogPresent
if live updates are supported, . ApplyUpdate(Version, Uri, IIOManager) and Restart will fail if this ...
LogLevel LogLevel
The minimum Microsoft.Extensions.Logging.LogLevel to display in logs
Factory for scoping usage of IDatabaseContexts. Meant for use by Components
ITokenFactory tokenFactory
The ITokenFactory for the Application
Definition: Application.cs:66
For initially seeding a database
ISystemIdentityFactory for windows systems. Uses long running tasks due to potential networked domain...
Contains various cryptographic functions
bool AllowAnyOrigin
If any origin is allowed for CORS requests. This overrides AllowedOrigins
Configuration for the automatic update system
For downloading and installing BYOND extractions for a given system
For accessing the disk in a synchronous manner
DatabaseType DatabaseType
The Configuration.DatabaseType to create
void Ready(Exception initializationError)
Mark the IApplication as ready to run
Definition: Application.cs:406
IPostWriteHandler for Windows systems
bool IsWindows
If the current platform is a Windows platform
readonly TaskCompletionSource< object > startupTcs
The TaskCompletionSource<TResult> used for determining when the Application is Ready(Exception) ...
Definition: Application.cs:61
void ConfigureServices(IServiceCollection services)
Configure dependency injected services
Definition: Application.cs:88
TokenValidationParameters ValidationParameters
The TokenValidationParameters for the ITokenFactory
Configuration options for the Models.DatabaseContext<TParentContext>
IIOManager that resolves paths to Environment.CurrentDirectory
Application(IConfiguration configuration, Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment)
Construct an Application
Definition: Application.cs:73
For injecting System.Security.Claims.Claims that Controllers.TgsAuthorizeAttribute can look for ...
DatabaseType
Type of database to user
Definition: DatabaseType.cs:6
The command line Configuration setup wizard
Definition: ISetupWizard.cs:9
Task< bool > CheckRunWizard(CancellationToken cancellationToken)
Run the setup wizard if necessary
Interface for using filesystems
Definition: IIOManager.cs:11
On Windows, DreamDaemon will show an unskippable prompt when using /world/proc/OpenPort(). This looks out for those prompts and immediately clicks "Yes" if the owning process has registered for it
For identifying the current platform
readonly IConfiguration configuration
The IConfiguration for the Application
Definition: Application.cs:51
string ConcatPath(params string[] paths)
Combines an array of strings into a path
Represents a service that may take an updated Host assembly and run it, stopping the current assembly...