chore(deps): update wiki digest to d57f0d1
[ArchiSteamFarm.git] / ArchiSteamFarm / IPC / ArchiKestrel.cs
blob807b40bcfe18e093799f370850e5ecbb892c99d1
1 // ----------------------------------------------------------------------------------------------
2 // _ _ _ ____ _ _____
3 // / \ _ __ ___ | |__ (_)/ ___| | |_ ___ __ _ _ __ ___ | ___|__ _ _ __ _ __ ___
4 // / _ \ | '__|/ __|| '_ \ | |\___ \ | __|/ _ \ / _` || '_ ` _ \ | |_ / _` || '__|| '_ ` _ \
5 // / ___ \ | | | (__ | | | || | ___) || |_| __/| (_| || | | | | || _|| (_| || | | | | | | |
6 // /_/ \_\|_| \___||_| |_||_||____/ \__|\___| \__,_||_| |_| |_||_| \__,_||_| |_| |_| |_|
7 // ----------------------------------------------------------------------------------------------
8 // |
9 // Copyright 2015-2024 Ɓukasz "JustArchi" Domeradzki
10 // Contact: JustArchi@JustArchi.net
11 // |
12 // Licensed under the Apache License, Version 2.0 (the "License");
13 // you may not use this file except in compliance with the License.
14 // You may obtain a copy of the License at
15 // |
16 // http://www.apache.org/licenses/LICENSE-2.0
17 // |
18 // Unless required by applicable law or agreed to in writing, software
19 // distributed under the License is distributed on an "AS IS" BASIS,
20 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 // See the License for the specific language governing permissions and
22 // limitations under the License.
24 using System;
25 using System.Collections.Generic;
26 using System.Diagnostics.CodeAnalysis;
27 using System.IO;
28 using System.Linq;
29 using System.Net;
30 using System.Reflection;
31 using System.Text.Json;
32 using System.Text.Json.Nodes;
33 using System.Threading.Tasks;
34 using ArchiSteamFarm.Core;
35 using ArchiSteamFarm.Helpers.Json;
36 using ArchiSteamFarm.IPC.Controllers.Api;
37 using ArchiSteamFarm.IPC.Integration;
38 using ArchiSteamFarm.Localization;
39 using ArchiSteamFarm.NLog;
40 using ArchiSteamFarm.NLog.Targets;
41 using ArchiSteamFarm.Plugins;
42 using ArchiSteamFarm.Plugins.Interfaces;
43 using ArchiSteamFarm.Storage;
44 using Microsoft.AspNetCore.Builder;
45 using Microsoft.AspNetCore.Hosting;
46 using Microsoft.AspNetCore.Http;
47 using Microsoft.AspNetCore.Http.Headers;
48 using Microsoft.AspNetCore.HttpOverrides;
49 using Microsoft.AspNetCore.StaticFiles;
50 using Microsoft.Extensions.Configuration;
51 using Microsoft.Extensions.DependencyInjection;
52 using Microsoft.Extensions.FileProviders;
53 using Microsoft.Extensions.Logging;
54 using Microsoft.Net.Http.Headers;
55 using Microsoft.OpenApi.Models;
56 using NLog.Web;
57 using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
59 namespace ArchiSteamFarm.IPC;
61 internal static class ArchiKestrel {
62 internal static bool IsRunning => WebApplication != null;
64 internal static HistoryTarget? HistoryTarget { get; private set; }
66 private static WebApplication? WebApplication;
68 internal static void OnNewHistoryTarget(HistoryTarget? historyTarget = null) {
69 if (HistoryTarget != null) {
70 HistoryTarget.NewHistoryEntry -= NLogController.OnNewHistoryEntry;
71 HistoryTarget = null;
74 if (historyTarget != null) {
75 historyTarget.NewHistoryEntry += NLogController.OnNewHistoryEntry;
76 HistoryTarget = historyTarget;
80 internal static async Task Start() {
81 if (WebApplication != null) {
82 return;
85 ASF.ArchiLogger.LogGenericInfo(Strings.IPCStarting);
87 // Init history logger for /Api/Log usage
88 Logging.InitHistoryLogger();
90 WebApplication webApplication = await CreateWebApplication().ConfigureAwait(false);
92 try {
93 // Start the server
94 await webApplication.StartAsync().ConfigureAwait(false);
95 } catch (Exception e) {
96 ASF.ArchiLogger.LogGenericException(e);
98 await webApplication.DisposeAsync().ConfigureAwait(false);
100 return;
103 WebApplication = webApplication;
105 ASF.ArchiLogger.LogGenericInfo(Strings.IPCReady);
108 internal static async Task Stop() {
109 if (WebApplication == null) {
110 return;
113 await WebApplication.StopAsync().ConfigureAwait(false);
114 await WebApplication.DisposeAsync().ConfigureAwait(false);
116 WebApplication = null;
119 [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "PathString is a primitive, it's unlikely to be trimmed to the best of our knowledge")]
120 [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL3000", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
121 private static void ConfigureApp([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] ConfigurationManager configuration, IApplicationBuilder app) {
122 ArgumentNullException.ThrowIfNull(configuration);
123 ArgumentNullException.ThrowIfNull(app);
125 // The order of dependency injection is super important, doing things in wrong order will most likely break everything
126 // https://docs.microsoft.com/aspnet/core/fundamentals/middleware
128 // This one is easy, it's always in the beginning
129 if (Debugging.IsUserDebugging) {
130 app.UseDeveloperExceptionPage();
133 // Add support for proxies, this one comes usually after developer exception page, but could be before
134 app.UseForwardedHeaders();
136 // Add support for response caching - must be called before static files as we want to cache those as well
137 if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
138 // As previously in services, we skip it if memory usage is super important for us
139 app.UseResponseCaching();
142 // Add support for response compression - must be called before static files as we want to compress those as well
143 app.UseResponseCompression();
145 // It's not apparent when UsePathBase() should be called, but definitely before we get down to static files
146 // TODO: Maybe eventually we can get rid of this, https://github.com/aspnet/AspNetCore/issues/5898
147 PathString pathBase = configuration.GetSection("Kestrel").GetValue<PathString>("PathBase");
149 if (!string.IsNullOrEmpty(pathBase) && (pathBase != "/")) {
150 app.UsePathBase(pathBase);
153 // The default HTML file (usually index.html) is responsible for IPC GUI routing, so re-execute all non-API calls on /
154 // This must be called before default files, because we don't know the exact file name that will be used for index page
155 app.UseWhen(static context => !context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseStatusCodePagesWithReExecute("/"));
157 // Add support for default root path redirection (GET / -> GET /index.html), must come before static files
158 app.UseDefaultFiles();
160 // Add support for additional default files provided by plugins
161 Dictionary<string, string> pluginPaths = new(StringComparer.Ordinal);
163 foreach (IWebInterface plugin in PluginsCore.ActivePlugins.OfType<IWebInterface>()) {
164 string physicalPath = plugin.PhysicalPath;
166 if (string.IsNullOrEmpty(physicalPath)) {
167 // Invalid path provided
168 ASF.ArchiLogger.LogGenericError(Strings.FormatErrorObjectIsNull($"{nameof(physicalPath)} ({plugin.Name})"));
170 continue;
173 string webPath = plugin.WebPath;
175 if (string.IsNullOrEmpty(webPath)) {
176 // Invalid path provided
177 ASF.ArchiLogger.LogGenericError(Strings.FormatErrorObjectIsNull($"{nameof(webPath)} ({plugin.Name})"));
179 continue;
182 if (!Path.IsPathRooted(physicalPath)) {
183 // Relative path
184 string? assemblyDirectory = Path.GetDirectoryName(plugin.GetType().Assembly.Location);
186 if (string.IsNullOrEmpty(assemblyDirectory)) {
187 throw new InvalidOperationException(nameof(assemblyDirectory));
190 physicalPath = Path.Combine(assemblyDirectory, physicalPath);
193 if (!Directory.Exists(physicalPath)) {
194 // Non-existing path provided
195 ASF.ArchiLogger.LogGenericWarning(Strings.FormatErrorIsInvalid($"{nameof(physicalPath)} ({plugin.Name})"));
197 continue;
200 pluginPaths[physicalPath] = webPath;
202 if (webPath != "/") {
203 app.UseDefaultFiles(webPath);
207 // Add support for additional static files from custom plugins (e.g. HTML, CSS and JS)
208 foreach ((string physicalPath, string webPath) in pluginPaths) {
209 StaticFileOptions options = new() {
210 FileProvider = new PhysicalFileProvider(physicalPath),
211 OnPrepareResponse = OnPrepareResponse
214 if (webPath != "/") {
215 options.RequestPath = webPath;
218 app.UseStaticFiles(options);
221 // Add support for static files (e.g. HTML, CSS and JS from IPC GUI)
222 app.UseStaticFiles(
223 new StaticFileOptions {
224 OnPrepareResponse = OnPrepareResponse
228 // Use routing for our API controllers, this should be called once we're done with all the static files mess
229 app.UseRouting();
231 // We want to protect our API with IPCPassword and additional security, this should be called after routing, so the middleware won't have to deal with API endpoints that do not exist
232 app.UseWhen(static context => context.Request.Path.StartsWithSegments("/Api", StringComparison.OrdinalIgnoreCase), static appBuilder => appBuilder.UseMiddleware<ApiAuthenticationMiddleware>());
234 // Add support for CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API
235 string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
237 if (!string.IsNullOrEmpty(ipcPassword)) {
238 // We apply CORS policy only with IPCPassword set as an extra authentication measure
239 app.UseCors();
242 // Add support for websockets that we use e.g. in /Api/NLog
243 app.UseWebSockets();
245 // Add additional endpoints provided by plugins
246 foreach (IWebServiceProvider plugin in PluginsCore.ActivePlugins.OfType<IWebServiceProvider>()) {
247 try {
248 plugin.OnConfiguringEndpoints(app);
249 } catch (Exception e) {
250 ASF.ArchiLogger.LogGenericException(e);
254 // Finally register proper API endpoints once we're done with routing
255 app.UseEndpoints(static endpoints => endpoints.MapControllers());
257 // Add support for swagger, responsible for automatic API documentation generation, this should be on the end, once we're done with API
258 app.UseSwagger();
260 // Add support for swagger UI, this should be after swagger, obviously
261 app.UseSwaggerUI(
262 static options => {
263 options.DisplayRequestDuration();
264 options.EnableDeepLinking();
265 options.EnableTryItOutByDefault();
266 options.ShowCommonExtensions();
267 options.ShowExtensions();
268 options.SwaggerEndpoint($"{SharedInfo.ASF}/swagger.json", $"{SharedInfo.ASF} API");
273 [UnconditionalSuppressMessage("AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", Justification = "We don't care about trimmed assemblies, as we need it to work only with the known (used) ones")]
274 private static void ConfigureServices([SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] ConfigurationManager configuration, IServiceCollection services) {
275 ArgumentNullException.ThrowIfNull(configuration);
276 ArgumentNullException.ThrowIfNull(services);
278 // The order of dependency injection is super important, doing things in wrong order will most likely break everything
279 // https://docs.microsoft.com/aspnet/core/fundamentals/middleware
281 // Prepare knownNetworks that we'll use in a second
282 HashSet<string>? knownNetworksTexts = configuration.GetSection("Kestrel:KnownNetworks").Get<HashSet<string>>();
284 HashSet<IPNetwork>? knownNetworks = null;
286 if (knownNetworksTexts?.Count > 0) {
287 // Use specified known networks
288 knownNetworks = [];
290 foreach (string knownNetworkText in knownNetworksTexts) {
291 string[] addressParts = knownNetworkText.Split('/', 3, StringSplitOptions.RemoveEmptyEntries);
293 if ((addressParts.Length != 2) || !IPAddress.TryParse(addressParts[0], out IPAddress? ipAddress) || !byte.TryParse(addressParts[1], out byte prefixLength)) {
294 ASF.ArchiLogger.LogGenericError(Strings.FormatErrorIsInvalid(nameof(knownNetworkText)));
295 ASF.ArchiLogger.LogGenericDebug($"{nameof(knownNetworkText)}: {knownNetworkText}");
297 continue;
300 knownNetworks.Add(new IPNetwork(ipAddress, prefixLength));
304 // Add support for proxies
305 services.Configure<ForwardedHeadersOptions>(
306 options => {
307 options.ForwardedHeaders = ForwardedHeaders.All;
309 if (knownNetworks != null) {
310 foreach (IPNetwork knownNetwork in knownNetworks) {
311 options.KnownNetworks.Add(knownNetwork);
317 // Add support for response caching
318 if (ASF.GlobalConfig?.OptimizationMode != GlobalConfig.EOptimizationMode.MinMemoryUsage) {
319 // We can skip it if memory usage is super important for us
320 services.AddResponseCaching();
323 // Add support for response compression
324 services.AddResponseCompression(static options => options.EnableForHttps = true);
326 // Add support for CORS policy in order to allow userscripts and other third-party integrations to communicate with ASF API
327 string? ipcPassword = ASF.GlobalConfig != null ? ASF.GlobalConfig.IPCPassword : GlobalConfig.DefaultIPCPassword;
329 if (!string.IsNullOrEmpty(ipcPassword)) {
330 // We apply CORS policy only with IPCPassword set as an extra authentication measure
331 services.AddCors(static options => options.AddDefaultPolicy(static policyBuilder => policyBuilder.AllowAnyOrigin()));
334 // Add support for swagger, responsible for automatic API documentation generation
335 services.AddSwaggerGen(
336 static options => {
337 options.AddSecurityDefinition(
338 nameof(GlobalConfig.IPCPassword), new OpenApiSecurityScheme {
339 Description = $"{nameof(GlobalConfig.IPCPassword)} authentication using request headers. Check {SharedInfo.ProjectURL}/wiki/IPC#authentication for more info.",
340 In = ParameterLocation.Header,
341 Name = ApiAuthenticationMiddleware.HeadersField,
342 Type = SecuritySchemeType.ApiKey
346 options.AddSecurityRequirement(
347 new OpenApiSecurityRequirement {
349 new OpenApiSecurityScheme {
350 Reference = new OpenApiReference {
351 Id = nameof(GlobalConfig.IPCPassword),
352 Type = ReferenceType.SecurityScheme
361 // We require custom schema IDs due to conflicting type names, choosing the proper one is tricky as there is no good answer and any kind of convention has a potential to create conflict
362 // FullName and Name both do, ToString() for unknown to me reason doesn't, and I don't have courage to call our WebUtilities.GetUnifiedName() better than what .NET ships with (because it isn't)
363 // Let's use ToString() until we find a good enough reason to change it, also, the name must pass ^[a-zA-Z0-9.-_]+$ regex
364 options.CustomSchemaIds(static type => type.ToString().Replace('+', '-'));
366 options.EnableAnnotations(true, true);
368 options.SchemaFilter<CustomAttributesSchemaFilter>();
369 options.SchemaFilter<EnumSchemaFilter>();
370 options.SchemaFilter<ReadOnlyFixesSchemaFilter>();
372 options.SwaggerDoc(
373 SharedInfo.ASF, new OpenApiInfo {
374 Contact = new OpenApiContact {
375 Name = SharedInfo.GithubRepo,
376 Url = new Uri(SharedInfo.ProjectURL)
379 License = new OpenApiLicense {
380 Name = SharedInfo.LicenseName,
381 Url = new Uri(SharedInfo.LicenseURL)
384 Title = $"{SharedInfo.AssemblyName} API",
385 Version = SharedInfo.Version.ToString()
389 string xmlDocumentationFile = Path.Combine(AppContext.BaseDirectory, SharedInfo.AssemblyDocumentation);
391 if (File.Exists(xmlDocumentationFile)) {
392 options.IncludeXmlComments(xmlDocumentationFile);
397 // Add support for optional healtchecks
398 services.AddHealthChecks();
400 // Add support for additional services provided by plugins
401 foreach (IWebServiceProvider plugin in PluginsCore.ActivePlugins.OfType<IWebServiceProvider>()) {
402 try {
403 plugin.OnConfiguringServices(services);
404 } catch (Exception e) {
405 ASF.ArchiLogger.LogGenericException(e);
409 // We need MVC for /Api, but we're going to use only a small subset of all available features
410 IMvcBuilder mvc = services.AddControllers();
412 // Add support for additional controllers provided by plugins
413 HashSet<Assembly>? assemblies = PluginsCore.LoadAssemblies();
415 if (assemblies != null) {
416 foreach (Assembly assembly in assemblies) {
417 mvc.AddApplicationPart(assembly);
421 // Register discovered controllers
422 mvc.AddControllersAsServices();
424 // Modify default JSON options
425 mvc.AddJsonOptions(
426 static options => {
427 JsonSerializerOptions jsonSerializerOptions = Debugging.IsUserDebugging ? JsonUtilities.IndentedJsonSerialierOptions : JsonUtilities.DefaultJsonSerialierOptions;
429 options.JsonSerializerOptions.PropertyNamingPolicy = jsonSerializerOptions.PropertyNamingPolicy;
430 options.JsonSerializerOptions.TypeInfoResolver = jsonSerializerOptions.TypeInfoResolver;
431 options.JsonSerializerOptions.WriteIndented = jsonSerializerOptions.WriteIndented;
436 private static async Task<WebApplication> CreateWebApplication() {
437 // Try to initialize to custom www folder first
438 string? webRootPath = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.WebsiteDirectory);
440 if (!Directory.Exists(webRootPath)) {
441 // Try to initialize to standard www folder next
442 webRootPath = Path.Combine(AppContext.BaseDirectory, SharedInfo.WebsiteDirectory);
444 if (!Directory.Exists(webRootPath)) {
445 // Do not attempt to create a new directory, user has explicitly removed it
446 webRootPath = null;
450 // The order of dependency injection matters, pay attention to it
451 WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(
452 new WebApplicationOptions {
453 ApplicationName = SharedInfo.AssemblyName,
454 ContentRootPath = SharedInfo.HomeDirectory,
455 WebRootPath = webRootPath
459 // Enable NLog integration for logging
460 builder.Logging.SetMinimumLevel(Debugging.IsUserDebugging ? LogLevel.Trace : LogLevel.Warning);
461 builder.Logging.AddNLogWeb(new NLogAspNetCoreOptions { ShutdownOnDispose = false });
463 // Check if custom config is available
464 string absoluteConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), SharedInfo.ConfigDirectory);
465 string customConfigPath = Path.Combine(absoluteConfigDirectory, SharedInfo.IPCConfigFile);
466 bool customConfigExists = File.Exists(customConfigPath);
468 if (customConfigExists) {
469 if (Debugging.IsDebugConfigured) {
470 try {
471 string json = await File.ReadAllTextAsync(customConfigPath).ConfigureAwait(false);
473 if (!string.IsNullOrEmpty(json)) {
474 JsonNode? jsonNode = JsonNode.Parse(json);
476 ASF.ArchiLogger.LogGenericDebug($"{SharedInfo.IPCConfigFile}: {jsonNode?.ToJsonText(true) ?? "null"}");
478 } catch (Exception e) {
479 ASF.ArchiLogger.LogGenericException(e);
483 // Set up custom config to be used
484 builder.WebHost.UseConfiguration(new ConfigurationBuilder().SetBasePath(absoluteConfigDirectory).AddJsonFile(SharedInfo.IPCConfigFile, false, true).Build());
487 builder.WebHost.ConfigureKestrel(
488 options => {
489 options.AddServerHeader = false;
491 if (customConfigExists) {
492 // Use custom config for Kestrel configuration
493 options.Configure(builder.Configuration.GetSection("Kestrel"));
494 } else {
495 // Use ASFB defaults for Kestrel
496 options.ListenLocalhost(1242);
501 if (customConfigExists) {
502 // User might be using HTTPS when providing custom config, use full implementation of Kestrel for that scenario
503 builder.WebHost.UseKestrel();
504 } else {
505 // We don't need extra features when not using custom config
506 builder.WebHost.UseKestrelCore();
509 ConfigureServices(builder.Configuration, builder.Services);
511 WebApplication result = builder.Build();
513 ConfigureApp(builder.Configuration, result);
515 return result;
518 private static void OnPrepareResponse(StaticFileResponseContext context) {
519 ArgumentNullException.ThrowIfNull(context);
521 if (context.File is not { Exists: true, IsDirectory: false } || string.IsNullOrEmpty(context.File.Name)) {
522 return;
525 string extension = Path.GetExtension(context.File.Name);
527 CacheControlHeaderValue cacheControl = new();
529 switch (extension.ToUpperInvariant()) {
530 case ".CSS" or ".JS":
531 // Add support for SRI-protected static files
532 // SRI requires from us to notify the caller (especially proxy) to avoid modifying the data
533 cacheControl.NoTransform = true;
535 goto default;
536 default:
537 // Instruct the caller to always ask us first about every file it requests
538 // Contrary to the name, this doesn't prevent client from caching, but rather informs it that it must verify with us first that their cache is still up-to-date
539 // This is used to handle ASF and user updates to WWW root, we don't want the client to ever use outdated scripts
540 cacheControl.NoCache = true;
542 // All static files are public by definition, we don't have any authorization here
543 cacheControl.Public = true;
545 break;
548 ResponseHeaders headers = context.Context.Response.GetTypedHeaders();
550 headers.CacheControl = cacheControl;