1158 lines
42 KiB
C#
1158 lines
42 KiB
C#
// Copyright Epic Games, Inc. All Rights Reserved.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Mime;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using EpicGames.AspNet;
|
|
using EpicGames.Core;
|
|
using EpicGames.Horde.Acls;
|
|
using EpicGames.Horde.Agents;
|
|
using EpicGames.Horde.Agents.Leases;
|
|
using EpicGames.Horde.Commits;
|
|
using EpicGames.Horde.Jobs;
|
|
using EpicGames.Horde.Logs;
|
|
using EpicGames.Horde.Projects;
|
|
using EpicGames.Horde.Server;
|
|
using EpicGames.Horde.Storage;
|
|
using EpicGames.Horde.Streams;
|
|
using EpicGames.Horde.Users;
|
|
using EpicGames.Redis;
|
|
using Grpc.Core;
|
|
using Grpc.Core.Interceptors;
|
|
using HordeCommon;
|
|
using HordeServer.Accounts;
|
|
using HordeServer.Acls;
|
|
using HordeServer.Auditing;
|
|
using HordeServer.Authentication;
|
|
using HordeServer.Configuration;
|
|
using HordeServer.Dashboard;
|
|
using HordeServer.Perforce;
|
|
using HordeServer.Plugins;
|
|
using HordeServer.Server;
|
|
using HordeServer.Server.Notices;
|
|
using HordeServer.ServiceAccounts;
|
|
using HordeServer.Users;
|
|
using HordeServer.Utilities;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Http.Features;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
|
using Microsoft.AspNetCore.Mvc.Filters;
|
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
|
using Microsoft.AspNetCore.ResponseCompression;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.Extensions.Primitives;
|
|
using Microsoft.OpenApi.Models;
|
|
using MongoDB.Bson;
|
|
using MongoDB.Bson.Serialization;
|
|
using MongoDB.Bson.Serialization.Conventions;
|
|
using MongoDB.Bson.Serialization.Serializers;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using StackExchange.Redis;
|
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
|
using Status = Grpc.Core.Status;
|
|
using StatusCode = Grpc.Core.StatusCode;
|
|
|
|
#pragma warning disable CA1505 // 'ConfigureServices' has a maintainability index of '9'. Rewrite or refactor the code to increase its maintainability index (MI) above '9'.
|
|
|
|
namespace HordeServer
|
|
{
|
|
using ContentHash = EpicGames.Core.ContentHash;
|
|
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
|
using JsonObject = System.Text.Json.Nodes.JsonObject;
|
|
|
|
class Startup : IServerStartup
|
|
{
|
|
static Startup()
|
|
{
|
|
ProtoBuf.Meta.RuntimeTypeModel.Default[typeof(ProjectId)].SetSurrogate(typeof(StringIdProto<ProjectId, ProjectIdConverter>));
|
|
ProtoBuf.Meta.RuntimeTypeModel.Default[typeof(StreamId)].SetSurrogate(typeof(StringIdProto<StreamId, StreamIdConverter>));
|
|
}
|
|
|
|
class GrpcExceptionInterceptor : Interceptor
|
|
{
|
|
readonly ILogger<GrpcExceptionInterceptor> _logger;
|
|
|
|
public GrpcExceptionInterceptor(ILogger<GrpcExceptionInterceptor> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public override Task<TResponse> UnaryServerHandler<TRequest, TResponse>(TRequest request, ServerCallContext context, UnaryServerMethod<TRequest, TResponse> continuation)
|
|
{
|
|
return GuardAsync(context, () => base.UnaryServerHandler(request, context, continuation));
|
|
}
|
|
|
|
public override Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(IAsyncStreamReader<TRequest> requestStream, ServerCallContext context, ClientStreamingServerMethod<TRequest, TResponse> continuation) where TRequest : class where TResponse : class
|
|
{
|
|
return GuardAsync(context, () => base.ClientStreamingServerHandler(requestStream, context, continuation));
|
|
}
|
|
|
|
public override Task ServerStreamingServerHandler<TRequest, TResponse>(TRequest request, IServerStreamWriter<TResponse> responseStream, ServerCallContext context, ServerStreamingServerMethod<TRequest, TResponse> continuation) where TRequest : class where TResponse : class
|
|
{
|
|
return GuardAsync(context, () => base.ServerStreamingServerHandler(request, responseStream, context, continuation));
|
|
}
|
|
|
|
public override Task DuplexStreamingServerHandler<TRequest, TResponse>(IAsyncStreamReader<TRequest> requestStream, IServerStreamWriter<TResponse> responseStream, ServerCallContext context, DuplexStreamingServerMethod<TRequest, TResponse> continuation) where TRequest : class where TResponse : class
|
|
{
|
|
return GuardAsync(context, () => base.DuplexStreamingServerHandler(requestStream, responseStream, context, continuation));
|
|
}
|
|
|
|
async Task<T> GuardAsync<T>(ServerCallContext context, Func<Task<T>> callFunc) where T : class
|
|
{
|
|
T result = null!;
|
|
await GuardAsync(context, async () => { result = await callFunc(); });
|
|
return result;
|
|
}
|
|
|
|
async Task GuardAsync(ServerCallContext context, Func<Task> callFunc)
|
|
{
|
|
HttpContext httpContext = context.GetHttpContext();
|
|
|
|
AgentId? agentId = AclService.GetAgentId(httpContext.User);
|
|
if (agentId != null)
|
|
{
|
|
using IDisposable? scope = _logger.BeginScope("Agent: {AgentId}, RemoteIP: {RemoteIP}, Method: {Method}", agentId.Value, httpContext.Connection.RemoteIpAddress, context.Method);
|
|
await GuardInnerAsync(context, callFunc);
|
|
}
|
|
else
|
|
{
|
|
using IDisposable? scope = _logger.BeginScope("RemoteIP: {RemoteIP}, Method: {Method}", httpContext.Connection.RemoteIpAddress, context.Method);
|
|
await GuardInnerAsync(context, callFunc);
|
|
}
|
|
}
|
|
|
|
async Task GuardInnerAsync(ServerCallContext context, Func<Task> callFunc)
|
|
{
|
|
try
|
|
{
|
|
await callFunc();
|
|
}
|
|
catch (OperationCanceledException ex)
|
|
{
|
|
_logger.LogDebug(ex, "Rpc call to {Method} was cancelled", context.Method);
|
|
throw;
|
|
}
|
|
catch (StructuredRpcException ex)
|
|
{
|
|
#pragma warning disable CA2254 // Template should be a static expression
|
|
_logger.LogError(ex, ex.Format, ex.Args);
|
|
#pragma warning restore CA2254 // Template should be a static expression
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Exception in call to {Method}", context.Method);
|
|
throw new RpcException(new Status(StatusCode.Internal, $"An exception was thrown on the server: {ex}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
class BsonSerializationProvider : IBsonSerializationProvider
|
|
{
|
|
public IBsonSerializer? GetSerializer(Type type)
|
|
{
|
|
if (type == typeof(ContentHash))
|
|
{
|
|
return new ContentHashSerializer();
|
|
}
|
|
if (type == typeof(DateTimeOffset))
|
|
{
|
|
return new DateTimeOffsetStringSerializer();
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
class ObsoleteLoggingFilter : IActionFilter
|
|
{
|
|
public void OnActionExecuted(ActionExecutedContext context)
|
|
{
|
|
}
|
|
|
|
public void OnActionExecuting(ActionExecutingContext context)
|
|
{
|
|
ControllerActionDescriptor? actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
|
|
if (actionDescriptor?.MethodInfo.GetCustomAttribute<ObsoleteAttribute>() != null
|
|
|| actionDescriptor?.MethodInfo?.DeclaringType?.GetCustomAttribute<ObsoleteAttribute>() != null)
|
|
{
|
|
ILogger? logger = context.HttpContext.RequestServices.GetService<ILogger<ObsoleteLoggingFilter>>();
|
|
logger?.LogDebug("Using obsolete endpoint: {RequestPath} (Source: {RemoteIp})", context.HttpContext.Request.Path, context.HttpContext.Connection.RemoteIpAddress);
|
|
}
|
|
}
|
|
}
|
|
|
|
public Startup(IConfiguration configuration)
|
|
{
|
|
Configuration = configuration;
|
|
}
|
|
|
|
public IConfiguration Configuration { get; }
|
|
|
|
public static void BindServerSettings(IConfiguration configuration, ServerSettings settings, ILogger? logger = null)
|
|
{
|
|
IConfigurationSection hordeSection = configuration.GetSection("Horde");
|
|
hordeSection.Bind(settings);
|
|
settings.Validate(logger);
|
|
settings.AddDefaultOidcScopesAndMappings();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converter to/from Redis values
|
|
/// </summary>
|
|
sealed class AgentIdRedisConverter : IRedisConverter<AgentId>
|
|
{
|
|
/// <inheritdoc/>
|
|
public AgentId FromRedisValue(RedisValue value) => new AgentId((string)value!);
|
|
|
|
/// <inheritdoc/>
|
|
public RedisValue ToRedisValue(AgentId value) => value.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configure custom types for the public API documentation
|
|
/// </summary>
|
|
class SwaggerSchemaFilter : ISchemaFilter
|
|
{
|
|
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
|
{
|
|
if (context.Type == typeof(AgentId))
|
|
{
|
|
}
|
|
if (context.Type == typeof(Utf8String) || context.Type.GetCustomAttribute<JsonSchemaStringAttribute>() != null)
|
|
{
|
|
schema.Type = "string";
|
|
schema.Properties.Clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
// This method gets called *multiple times* by the runtime. Use this method to add services to the container.
|
|
public void ConfigureServices(IServiceCollection services)
|
|
{
|
|
services.AddSingleton<MemoryMappedFileCache>();
|
|
services.AddSingleton<JsonSchemaCache>();
|
|
|
|
// IOptionsMonitor pattern for live updating of configuration settings
|
|
services.Configure<ServerSettings>(x => BindServerSettings(Configuration, x));
|
|
|
|
// We may upload a large number of references with posts to storage endpoints. Increase the max number of form values to allow this (the default is 1024).
|
|
services.Configure<FormOptions>(options =>
|
|
{
|
|
options.ValueCountLimit = 100_000;
|
|
});
|
|
|
|
// Bind the settings again for local variable access in this method
|
|
ServerSettings settings = new();
|
|
BindServerSettings(Configuration, settings);
|
|
|
|
ServerInfo serverInfo = new ServerInfo(Configuration, Options.Create(settings));
|
|
services.AddSingleton<IServerInfo>(serverInfo);
|
|
|
|
// Register the plugin collection
|
|
IPluginCollection pluginCollection = ServerApp.Plugins;
|
|
services.AddSingleton<IPluginCollection>(pluginCollection);
|
|
|
|
// Register all the plugin services
|
|
IConfigurationSection pluginsConfig = Configuration.GetSection("Horde").GetSection("Plugins");
|
|
foreach (ILoadedPlugin plugin in pluginCollection.LoadedPlugins)
|
|
{
|
|
IConfigurationSection pluginConfig = pluginsConfig.GetSection(plugin.Name.ToString());
|
|
plugin.ConfigureServices(pluginConfig, serverInfo, services);
|
|
}
|
|
|
|
OpenTelemetryHelper.Configure(services, settings.OpenTelemetry);
|
|
|
|
if (settings.GlobalThreadPoolMinSize != null)
|
|
{
|
|
// Min thread pool size is set to combat timeouts seen with the Redis client.
|
|
// See comments for <see cref="ServerSettings.GlobalThreadPoolMinSize" /> and
|
|
// https://github.com/StackExchange/StackExchange.Redis/issues/1680
|
|
int min = settings.GlobalThreadPoolMinSize.Value;
|
|
ThreadPool.SetMinThreads(min, min);
|
|
}
|
|
|
|
RedisSerializer.RegisterConverter<AgentId, AgentIdRedisConverter>();
|
|
|
|
#pragma warning disable CA2000 // Dispose objects before losing scope
|
|
RedisService redisService;
|
|
using (Serilog.Extensions.Logging.SerilogLoggerFactory loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Serilog.Log.Logger))
|
|
{
|
|
redisService = new RedisService(Options.Create(settings), loggerFactory.CreateLogger<RedisService>());
|
|
}
|
|
#pragma warning restore CA2000 // Dispose objects before losing scope
|
|
services.AddSingleton<RedisService>(sp => redisService);
|
|
services.AddSingleton<IRedisService>(sp => sp.GetRequiredService<RedisService>());
|
|
services.AddDataProtection().PersistKeysToStackExchangeRedis(() => redisService.GetDatabase(), "aspnet-data-protection");
|
|
|
|
services.AddResponseCompression(options =>
|
|
{
|
|
options.EnableForHttps = true;
|
|
options.Providers.Add<GzipCompressionProvider>();
|
|
options.Providers.Add<BrotliCompressionProvider>();
|
|
options.Providers.Add<ZstdCompressionProvider>();
|
|
});
|
|
|
|
if (settings.CorsEnabled)
|
|
{
|
|
services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("CorsPolicy",
|
|
builder => builder.WithOrigins(settings.CorsOrigin.Split(";"))
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader()
|
|
.AllowCredentials());
|
|
});
|
|
}
|
|
|
|
services.AddGrpc(options =>
|
|
{
|
|
options.EnableDetailedErrors = true;
|
|
options.MaxReceiveMessageSize = 200 * 1024 * 1024; // 100 MB (packaged builds of Horde agent can be large)
|
|
options.Interceptors.Add(typeof(GrpcExceptionInterceptor));
|
|
});
|
|
services.AddGrpcReflection();
|
|
|
|
services.AddSingleton<IAccountCollection, AccountCollection>();
|
|
services.AddSingleton<IServiceAccountCollection, ServiceAccountCollection>();
|
|
services.AddSingleton<IUserCollection, UserCollectionV2>();
|
|
services.AddSingleton<INoticeCollection, NoticeCollection>();
|
|
services.AddSingleton<IDashboardPreviewCollection, DashboardPreviewCollection>();
|
|
|
|
services.AddSingleton<IConfigSource, InMemoryConfigSource>();
|
|
services.AddSingleton<IConfigSource, FileConfigSource>();
|
|
|
|
services.AddSingleton<ConfigService>();
|
|
services.AddSingleton<IConfigService>(sp => sp.GetRequiredService<ConfigService>());
|
|
services.AddSingleton<IOptionsFactory<GlobalConfig>>(sp => sp.GetRequiredService<ConfigService>());
|
|
services.AddSingleton<IOptionsChangeTokenSource<GlobalConfig>>(sp => sp.GetRequiredService<ConfigService>());
|
|
|
|
// Always run the hosted config service, regardless of the server role, so we receive updates on the latest config values.
|
|
services.AddHostedService(provider => provider.GetRequiredService<ConfigService>());
|
|
|
|
// Auditing
|
|
services.AddSingleton(typeof(IAuditLogFactory<>), typeof(AuditLogFactory<>));
|
|
services.AddSingleton(typeof(ISingletonDocument<>), typeof(SingletonDocument<>));
|
|
|
|
services.AddSingleton<IAclService, AclService>();
|
|
services.AddSingleton<RequestTrackerService>();
|
|
services.AddSingleton<MongoCommandTracer>();
|
|
services.AddSingleton<MongoService>();
|
|
services.AddSingleton<IMongoService>(sp => sp.GetRequiredService<MongoService>());
|
|
services.AddSingleton<MongoMigrator>();
|
|
services.AddSingleton<GlobalsService>();
|
|
services.AddSingleton<IClock, Clock>();
|
|
services.AddSingleton<IDowntimeService, DowntimeService>();
|
|
services.AddSingleton<LifetimeService>();
|
|
services.AddSingleton<ILifetimeService>(sp => sp.GetRequiredService<LifetimeService>());
|
|
services.AddHostedService(provider => provider.GetRequiredService<LifetimeService>());
|
|
services.AddSingleton(typeof(IHealthMonitor<>), typeof(HealthMonitor<>));
|
|
services.AddSingleton<ServerStatusService>();
|
|
services.AddHostedService(provider => provider.GetRequiredService<ServerStatusService>());
|
|
|
|
services.AddScoped<OAuthControllerFilter>();
|
|
|
|
services.AddSingleton<NoticeService>();
|
|
|
|
AuthenticationBuilder authBuilder = services.AddAuthentication(options =>
|
|
{
|
|
switch (settings.AuthMethod)
|
|
{
|
|
case AuthMethod.Anonymous:
|
|
options.DefaultAuthenticateScheme = AnonymousAuthHandler.AuthenticationScheme;
|
|
options.DefaultSignInScheme = AnonymousAuthHandler.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = AnonymousAuthHandler.AuthenticationScheme;
|
|
break;
|
|
|
|
case AuthMethod.Okta:
|
|
// If an authentication cookie is present, use it to get authentication information
|
|
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
|
|
// If authentication is required, and no cookie is present, use OIDC to sign in
|
|
options.DefaultChallengeScheme = OktaAuthHandler.AuthenticationScheme;
|
|
break;
|
|
|
|
case AuthMethod.OpenIdConnect:
|
|
// If an authentication cookie is present, use it to get authentication information
|
|
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
|
|
// If authentication is required, and no cookie is present, use OIDC to sign in
|
|
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
|
|
break;
|
|
|
|
case AuthMethod.Horde:
|
|
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
break;
|
|
|
|
default:
|
|
throw new ArgumentException($"Invalid auth method {settings.AuthMethod}");
|
|
}
|
|
});
|
|
|
|
List<string> schemes = new List<string>();
|
|
|
|
authBuilder.AddCookie(options =>
|
|
{
|
|
if (settings.AuthMethod == AuthMethod.Horde)
|
|
{
|
|
options.Cookie.Name = CookieAuthenticationDefaults.AuthenticationScheme;
|
|
options.LoginPath = "/account/login/horde";
|
|
options.LogoutPath = "/";
|
|
}
|
|
|
|
options.Events.OnValidatePrincipal = context =>
|
|
{
|
|
if (!String.Equals(context.Principal?.FindFirst(HordeClaimTypes.Version)?.Value, HordeClaimTypes.CurrentVersion, StringComparison.Ordinal))
|
|
{
|
|
context.RejectPrincipal();
|
|
}
|
|
if (context.Principal?.FindFirst(HordeClaimTypes.UserId) == null)
|
|
{
|
|
context.RejectPrincipal();
|
|
}
|
|
return Task.CompletedTask;
|
|
};
|
|
|
|
options.Events.OnRedirectToAccessDenied = context =>
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
return context.Response.CompleteAsync();
|
|
};
|
|
});
|
|
schemes.Add(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
authBuilder.AddServiceAccounts(options => { });
|
|
schemes.Add(ServiceAccountAuthHandler.AuthenticationScheme);
|
|
|
|
ValidateOidcSettings(settings);
|
|
switch (settings.AuthMethod)
|
|
{
|
|
case AuthMethod.Anonymous:
|
|
authBuilder.AddAnonymous(options => { });
|
|
schemes.Add(AnonymousAuthHandler.AuthenticationScheme);
|
|
break;
|
|
|
|
case AuthMethod.Okta:
|
|
authBuilder.AddOkta(settings, OktaAuthHandler.AuthenticationScheme, OpenIdConnectDefaults.DisplayName, options =>
|
|
{
|
|
options.Authority = settings.OidcAuthority;
|
|
options.ClientId = settings.OidcClientId;
|
|
options.Scope.Remove("groups");
|
|
|
|
if (!String.IsNullOrEmpty(settings.OidcSigninRedirect))
|
|
{
|
|
options.Events = new OpenIdConnectEvents
|
|
{
|
|
OnRedirectToIdentityProvider = async redirectContext =>
|
|
{
|
|
redirectContext.ProtocolMessage.RedirectUri = settings.OidcSigninRedirect;
|
|
await Task.CompletedTask;
|
|
}
|
|
};
|
|
}
|
|
});
|
|
schemes.Add(OktaAuthHandler.AuthenticationScheme);
|
|
break;
|
|
|
|
case AuthMethod.OpenIdConnect:
|
|
authBuilder.AddHordeOpenId(settings, OpenIdConnectDefaults.AuthenticationScheme, OpenIdConnectDefaults.DisplayName, options =>
|
|
{
|
|
options.Authority = settings.OidcAuthority;
|
|
options.ClientId = settings.OidcClientId;
|
|
options.ClientSecret = settings.OidcClientSecret;
|
|
foreach (string scope in settings.OidcRequestedScopes)
|
|
{
|
|
options.Scope.Add(scope);
|
|
}
|
|
|
|
if (!String.IsNullOrEmpty(settings.OidcSigninRedirect))
|
|
{
|
|
options.Events = new OpenIdConnectEvents
|
|
{
|
|
OnRedirectToIdentityProvider = async redirectContext =>
|
|
{
|
|
redirectContext.ProtocolMessage.RedirectUri = settings.OidcSigninRedirect;
|
|
await Task.CompletedTask;
|
|
}
|
|
};
|
|
}
|
|
});
|
|
schemes.Add(OpenIdConnectDefaults.AuthenticationScheme);
|
|
break;
|
|
|
|
case AuthMethod.Horde:
|
|
authBuilder.AddHordeOpenId(settings, OpenIdConnectDefaults.AuthenticationScheme, OpenIdConnectDefaults.DisplayName, options =>
|
|
{
|
|
options.Authority = new Uri(settings.ServerUrl, "api/v1/oauth2").ToString();
|
|
options.ClientId = "default";
|
|
if (settings.HttpsPort == 0)
|
|
{
|
|
options.RequireHttpsMetadata = false;
|
|
}
|
|
foreach (string scope in settings.OidcRequestedScopes)
|
|
{
|
|
options.Scope.Add(scope);
|
|
}
|
|
});
|
|
schemes.Add(OpenIdConnectDefaults.AuthenticationScheme);
|
|
break;
|
|
|
|
default:
|
|
throw new ArgumentException($"Invalid auth method {settings.AuthMethod}");
|
|
}
|
|
|
|
authBuilder.AddScheme<JwtBearerOptions, JwtAuthHandler>(JwtAuthHandler.AuthenticationScheme, options => { });
|
|
schemes.Add(JwtAuthHandler.AuthenticationScheme);
|
|
|
|
if (!String.IsNullOrEmpty(settings.OidcAuthority) && !String.IsNullOrEmpty(settings.OidcAudience))
|
|
{
|
|
ExternalJwtAuthHandler hordeJwtBearer = new(settings);
|
|
hordeJwtBearer.AddHordeJwtBearerConfiguration(authBuilder);
|
|
schemes.Add(ExternalJwtAuthHandler.AuthenticationScheme);
|
|
}
|
|
|
|
services.AddAuthorization(options =>
|
|
{
|
|
options.DefaultPolicy = new AuthorizationPolicyBuilder(schemes.ToArray())
|
|
.RequireAuthenticatedUser()
|
|
.Build();
|
|
});
|
|
|
|
// Hosted service that needs to run no matter the run mode of the process (server vs worker)
|
|
services.AddHostedService(provider => (DowntimeService)provider.GetRequiredService<IDowntimeService>());
|
|
|
|
if (settings.IsRunModeActive(RunMode.Worker) && !settings.MongoReadOnlyMode)
|
|
{
|
|
services.AddHostedService<MetricService>();
|
|
}
|
|
|
|
// Allow longer to shutdown so we can debug missing cancellation tokens
|
|
services.Configure<HostOptions>(options =>
|
|
{
|
|
options.ShutdownTimeout = TimeSpan.FromSeconds(30.0);
|
|
});
|
|
|
|
// Allow forwarded headers
|
|
services.Configure<ForwardedHeadersOptions>(options =>
|
|
{
|
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
options.KnownProxies.Clear();
|
|
options.KnownNetworks.Clear();
|
|
});
|
|
|
|
IMvcBuilder mvcBuilder = services.AddMvc()
|
|
.AddJsonOptions(options => JsonUtils.ConfigureJsonSerializer(options.JsonSerializerOptions));
|
|
|
|
foreach (Assembly pluginAssembly in pluginCollection.LoadedPlugins.Select(x => x.Assembly).Distinct())
|
|
{
|
|
mvcBuilder.AddApplicationPart(pluginAssembly);
|
|
}
|
|
|
|
services.AddControllersWithViews(options => options.Filters.Add(new ObsoleteLoggingFilter()))
|
|
.AddRazorRuntimeCompilation();
|
|
|
|
services.AddControllers(options =>
|
|
{
|
|
options.InputFormatters.Add(new CbInputFormatter());
|
|
options.OutputFormatters.Add(new CbOutputFormatter());
|
|
options.OutputFormatters.Insert(0, new CbPreferredOutputFormatter());
|
|
options.OutputFormatters.Add(new RawOutputFormatter(NullLogger.Instance));
|
|
options.FormatterMappings.SetMediaTypeMappingForFormat("raw", MediaTypeNames.Application.Octet);
|
|
options.FormatterMappings.SetMediaTypeMappingForFormat("uecb", CustomMediaTypeNames.UnrealCompactBinary);
|
|
options.FormatterMappings.SetMediaTypeMappingForFormat("uecbpkg", CustomMediaTypeNames.UnrealCompactBinaryPackage);
|
|
}).ConfigureApiBehaviorOptions(options =>
|
|
{
|
|
options.InvalidModelStateResponseFactory = context =>
|
|
{
|
|
BadRequestObjectResult result = new BadRequestObjectResult(context.ModelState);
|
|
// always return errors as json objects
|
|
// we could allow more types here, but we do not want raw for instance
|
|
result.ContentTypes.Add(MediaTypeNames.Application.Json);
|
|
|
|
return result;
|
|
};
|
|
});
|
|
|
|
services.AddSwaggerGen(config =>
|
|
{
|
|
config.SchemaGeneratorOptions.SchemaFilters.Add(new SwaggerSchemaFilter());
|
|
config.SwaggerDoc("v1", new OpenApiInfo { Title = "Horde Server API", Version = "v1" });
|
|
|
|
string xmlDocFile = Path.Combine(AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml");
|
|
if (File.Exists(xmlDocFile))
|
|
{
|
|
config.IncludeXmlComments(xmlDocFile);
|
|
}
|
|
});
|
|
|
|
services.Configure<ApiBehaviorOptions>(options =>
|
|
{
|
|
options.InvalidModelStateResponseFactory = context =>
|
|
{
|
|
foreach (KeyValuePair<string, ModelStateEntry> pair in context.ModelState)
|
|
{
|
|
ModelError? error = pair.Value.Errors.FirstOrDefault();
|
|
if (error != null)
|
|
{
|
|
string message = error.ErrorMessage;
|
|
if (String.IsNullOrEmpty(message))
|
|
{
|
|
message = error.Exception?.Message ?? "Invalid error object";
|
|
}
|
|
return new BadRequestObjectResult(EpicGames.Core.LogEvent.Create(LogLevel.Error, KnownLogEvents.None, error.Exception, "Invalid value for {Name}: {Message}", pair.Key, message));
|
|
}
|
|
}
|
|
return new BadRequestObjectResult(context.ModelState);
|
|
};
|
|
});
|
|
|
|
DirectoryReference dashboardDir = DirectoryReference.Combine(ServerApp.AppDir, "DashboardApp");
|
|
if (DirectoryReference.Exists(dashboardDir))
|
|
{
|
|
services.AddSpaStaticFiles(config => { config.RootPath = "DashboardApp"; });
|
|
}
|
|
|
|
ConfigureMongoDbClient();
|
|
ConfigureFormatters();
|
|
|
|
OnAddHealthChecks(services);
|
|
}
|
|
|
|
private static void ValidateOidcSettings(ServerSettings settings)
|
|
{
|
|
if (settings.AuthMethod is AuthMethod.OpenIdConnect or AuthMethod.Okta)
|
|
{
|
|
if (settings.OidcAuthority == null)
|
|
{
|
|
throw new ArgumentException($"Key '{nameof(ServerSettings.OidcAuthority)}' in server settings must be set when auth mode {settings.AuthMethod} is used");
|
|
}
|
|
|
|
if (settings.OidcAudience == null)
|
|
{
|
|
throw new ArgumentException($"Key '{nameof(ServerSettings.OidcAudience)}' in server settings must be set when auth mode {settings.AuthMethod} is used");
|
|
}
|
|
|
|
if (settings.OidcClientId == null)
|
|
{
|
|
throw new ArgumentException($"Key '{nameof(ServerSettings.OidcClientId)}' in server settings must be set when auth mode {settings.AuthMethod} is used");
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void ConfigureFormatters()
|
|
{
|
|
LogValueFormatter.RegisterTypeAnnotation<AgentId>("AgentId");
|
|
LogValueFormatter.RegisterTypeAnnotation<JobId>("JobId");
|
|
LogValueFormatter.RegisterTypeAnnotation<LeaseId>("LeaseId");
|
|
LogValueFormatter.RegisterTypeAnnotation<LogId>("LogId");
|
|
LogValueFormatter.RegisterTypeAnnotation<UserId>("UserId");
|
|
}
|
|
|
|
public sealed class CommitIdBsonSerializer : SerializerBase<CommitId>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override CommitId Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
if (context.Reader.GetCurrentBsonType() == BsonType.Int32)
|
|
{
|
|
return CommitId.FromPerforceChange(context.Reader.ReadInt32());
|
|
}
|
|
else
|
|
{
|
|
return new CommitId(context.Reader.ReadString());
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, CommitId value)
|
|
{
|
|
context.Writer.WriteString(value.Name);
|
|
}
|
|
}
|
|
|
|
public sealed class BlobLocatorBsonSerializer : SerializerBase<BlobLocator>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override BlobLocator Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
return new BlobLocator(context.Reader.ReadString());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, BlobLocator value)
|
|
{
|
|
context.Writer.WriteString(value.ToString());
|
|
}
|
|
}
|
|
|
|
public sealed class RefNameBsonSerializer : SerializerBase<RefName>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override RefName Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
return new RefName(context.Reader.ReadString());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, RefName value)
|
|
{
|
|
context.Writer.WriteString(value.ToString());
|
|
}
|
|
}
|
|
|
|
public sealed class IoHashBsonSerializer : SerializerBase<IoHash>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override IoHash Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
return IoHash.Parse(context.Reader.ReadString());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, IoHash value)
|
|
{
|
|
context.Writer.WriteString(value.ToString());
|
|
}
|
|
}
|
|
|
|
public sealed class NamespaceIdBsonSerializer : SerializerBase<NamespaceId>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override NamespaceId Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
return new NamespaceId(context.Reader.ReadString());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, NamespaceId value)
|
|
{
|
|
context.Writer.WriteString(value.ToString());
|
|
}
|
|
}
|
|
|
|
public sealed class AgentIdBsonSerializer : SerializerBase<AgentId>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override AgentId Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
string argument;
|
|
if (context.Reader.CurrentBsonType == MongoDB.Bson.BsonType.ObjectId)
|
|
{
|
|
argument = context.Reader.ReadObjectId().ToString();
|
|
}
|
|
else
|
|
{
|
|
argument = context.Reader.ReadString();
|
|
}
|
|
return new AgentId(argument);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, AgentId value)
|
|
{
|
|
context.Writer.WriteString(value.ToString());
|
|
}
|
|
}
|
|
|
|
public sealed class AclActionBsonSerializer : SerializerBase<AclAction>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override AclAction Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
=> new AclAction(context.Reader.ReadString());
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, AclAction value)
|
|
=> context.Writer.WriteString(value.Name);
|
|
}
|
|
|
|
public sealed class AclScopeNameBsonSerializer : SerializerBase<AclScopeName>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override AclScopeName Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
=> new AclScopeName(context.Reader.ReadString());
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, AclScopeName value)
|
|
=> context.Writer.WriteString(value.Text ?? String.Empty);
|
|
}
|
|
|
|
sealed class SubResourceIdBsonSerializer<TValue, TConverter> : SerializerBase<TValue> where TValue : struct where TConverter : SubResourceIdConverter<TValue>, new()
|
|
{
|
|
readonly TConverter _converter = new TConverter();
|
|
|
|
/// <inheritdoc/>
|
|
public override TValue Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
=> _converter.FromSubResourceId(new SubResourceId((ushort)context.Reader.ReadInt32()));
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, TValue value)
|
|
=> context.Writer.WriteInt32(_converter.ToSubResourceId(value).Value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serializer for subresource ids
|
|
/// </summary>
|
|
public class SubResourceIdSerializer : IBsonSerializer<SubResourceId>
|
|
{
|
|
/// <inheritdoc/>
|
|
public Type ValueType => typeof(SubResourceId);
|
|
|
|
/// <inheritdoc/>
|
|
public object Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
return new SubResourceId((ushort)context.Reader.ReadInt32());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, object value)
|
|
{
|
|
SubResourceId id = (SubResourceId)value;
|
|
context.Writer.WriteInt32((int)id.Value);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Serialize(BsonSerializationContext context, BsonSerializationArgs args, SubResourceId id)
|
|
{
|
|
context.Writer.WriteInt32((int)id.Value);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
SubResourceId IBsonSerializer<SubResourceId>.Deserialize(BsonDeserializationContext context, BsonDeserializationArgs vrgs)
|
|
{
|
|
return new SubResourceId((ushort)context.Reader.ReadInt32());
|
|
}
|
|
}
|
|
|
|
sealed class SubResourceIdBsonSerializationProvider : BsonSerializationProviderBase
|
|
{
|
|
/// <inheritdoc/>
|
|
public override IBsonSerializer? GetSerializer(Type type, IBsonSerializerRegistry serializerRegistry)
|
|
{
|
|
SubResourceIdConverterAttribute? attribute = type.GetCustomAttribute<SubResourceIdConverterAttribute>();
|
|
if (attribute == null)
|
|
{
|
|
return null;
|
|
}
|
|
return (IBsonSerializer?)Activator.CreateInstance(typeof(SubResourceIdBsonSerializer<,>).MakeGenericType(type, attribute.ConverterType));
|
|
}
|
|
}
|
|
|
|
sealed class CommitTagBsonSerializer : SerializerBase<CommitTag>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override CommitTag Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
return new CommitTag(context.Reader.ReadString());
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, CommitTag value)
|
|
{
|
|
context.Writer.WriteString(value.ToString());
|
|
}
|
|
}
|
|
|
|
sealed class JsonObjectBsonSerializer : SerializerBase<JsonObject>
|
|
{
|
|
/// <inheritdoc/>
|
|
public override JsonObject Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
|
|
{
|
|
string str = context.Reader.ReadString();
|
|
if (String.IsNullOrWhiteSpace(str))
|
|
{
|
|
return new JsonObject();
|
|
}
|
|
else
|
|
{
|
|
return (JsonObject)JsonObject.Parse(str, new JsonNodeOptions { PropertyNameCaseInsensitive = true }, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip })!;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, JsonObject value)
|
|
=> context.Writer.WriteString(value.ToJsonString());
|
|
}
|
|
|
|
static int s_haveConfiguredMongoDb = 0;
|
|
|
|
public static void ConfigureMongoDbClient()
|
|
{
|
|
if (Interlocked.CompareExchange(ref s_haveConfiguredMongoDb, 1, 0) == 0)
|
|
{
|
|
// Ignore extra elements on deserialized documents
|
|
ConventionPack conventionPack = new ConventionPack();
|
|
conventionPack.Add(new IgnoreExtraElementsConvention(true));
|
|
conventionPack.Add(new EnumRepresentationConvention(BsonType.String));
|
|
ConventionRegistry.Register("Horde", conventionPack, type => true);
|
|
|
|
// Register the custom serializers
|
|
BsonSerializer.RegisterSerializer(new CommitIdBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new BlobLocatorBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new RefNameBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new IoHashBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new NamespaceIdBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new AgentIdBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new AclActionBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new AclScopeNameBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new ConditionSerializer());
|
|
BsonSerializer.RegisterSerializer(new SubResourceIdSerializer());
|
|
BsonSerializer.RegisterSerializer(new CommitTagBsonSerializer());
|
|
BsonSerializer.RegisterSerializer(new JsonObjectBsonSerializer());
|
|
BsonSerializer.RegisterSerializationProvider(new BsonSerializationProvider());
|
|
BsonSerializer.RegisterSerializationProvider(new StringIdBsonSerializationProvider());
|
|
BsonSerializer.RegisterSerializationProvider(new BinaryIdBsonSerializationProvider());
|
|
BsonSerializer.RegisterSerializationProvider(new ObjectIdBsonSerializationProvider());
|
|
BsonSerializer.RegisterSerializationProvider(new SubResourceIdBsonSerializationProvider());
|
|
}
|
|
}
|
|
|
|
private static void OnAddHealthChecks(IServiceCollection services)
|
|
{
|
|
services.AddHealthChecks()
|
|
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "self" })
|
|
.AddCheck<RedisService>("redis")
|
|
.AddCheck<MongoService>("mongo")
|
|
.AddCheck<PerforceLoadBalancer>("p4lb");
|
|
}
|
|
|
|
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
|
public static void Configure(IApplicationBuilder app, IWebHostEnvironment env, Microsoft.Extensions.Hosting.IHostApplicationLifetime lifetime, IOptions<ServerSettings> settings)
|
|
{
|
|
app.UseForwardedHeaders();
|
|
app.UseResponseCompression();
|
|
|
|
// Used for allowing auth cookies in combination with OpenID Connect auth (for example, Google Auth did not work with these unset)
|
|
app.UseCookiePolicy(new CookiePolicyOptions()
|
|
{
|
|
MinimumSameSitePolicy = SameSiteMode.None,
|
|
CheckConsentNeeded = _ => true
|
|
});
|
|
|
|
if (settings.Value.CorsEnabled)
|
|
{
|
|
app.UseCors("CorsPolicy");
|
|
}
|
|
|
|
// Enable middleware to serve generated Swagger as a JSON endpoint.
|
|
app.UseSwagger();
|
|
|
|
// Enable serilog request logging
|
|
app.UseSerilogRequestLogging(options =>
|
|
{
|
|
options.GetLevel = GetRequestLoggingLevel;
|
|
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
|
{
|
|
diagnosticContext.Set("RemoteIP", httpContext?.Connection?.RemoteIpAddress);
|
|
|
|
// Header sent by the dashboard to indicate how long a user has been inactive for a particular browser page (in seconds)
|
|
if (httpContext?.Request.Headers.TryGetValue("X-Horde-LastUserActivity", out StringValues values) is true)
|
|
{
|
|
string? value = values.FirstOrDefault();
|
|
if (!String.IsNullOrEmpty(value) && Int32.TryParse(value, out int lastUserActivity))
|
|
{
|
|
diagnosticContext.Set("LastUserActivity", lastUserActivity);
|
|
}
|
|
}
|
|
};
|
|
});
|
|
|
|
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
|
|
// specifying the Swagger JSON endpoint.
|
|
app.UseSwaggerUI(c =>
|
|
{
|
|
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Horde Server API");
|
|
c.RoutePrefix = "swagger";
|
|
});
|
|
|
|
if (!env.IsDevelopment())
|
|
{
|
|
app.UseMiddleware<RequestTrackerMiddleware>();
|
|
}
|
|
|
|
app.UseReverseProxyDetection();
|
|
app.UseExceptionHandler("/api/v1/exception");
|
|
|
|
DirectoryReference dashboardDir = DirectoryReference.Combine(ServerApp.AppDir, "DashboardApp");
|
|
if (DirectoryReference.Exists(dashboardDir))
|
|
{
|
|
app.UseDefaultFiles();
|
|
app.UseStaticFiles();
|
|
app.UseWhen(IsSpaRequest, builder => builder.UseSpaStaticFiles());
|
|
}
|
|
|
|
app.UseRouting();
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.UseEndpoints(endpoints =>
|
|
{
|
|
endpoints.MapGrpcService<HealthService>();
|
|
|
|
endpoints.MapGrpcReflectionService();
|
|
|
|
endpoints.MapControllers();
|
|
|
|
endpoints.MapHealthChecks("/health/live");
|
|
});
|
|
|
|
if (DirectoryReference.Exists(dashboardDir))
|
|
{
|
|
app.MapWhen(IsSpaRequest, builder => builder.UseSpa(spa => spa.Options.SourcePath = "DashboardApp"));
|
|
}
|
|
|
|
foreach (IPluginStartup pluginStartup in app.ApplicationServices.GetRequiredService<IEnumerable<IPluginStartup>>())
|
|
{
|
|
pluginStartup.Configure(app);
|
|
}
|
|
|
|
if (settings.Value.OpenBrowser && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
{
|
|
lifetime.ApplicationStarted.Register(() => LaunchBrowser(app));
|
|
}
|
|
}
|
|
|
|
static bool IsSpaRequest(HttpContext context)
|
|
{
|
|
return !context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
static void LaunchBrowser(IApplicationBuilder app)
|
|
{
|
|
IServerAddressesFeature? feature = app.ServerFeatures.Get<IServerAddressesFeature>();
|
|
if (feature != null && feature.Addresses.Count > 0)
|
|
{
|
|
// with a development cert, host will be set by default to localhost, otherwise there will be no host in address
|
|
string address = feature.Addresses.First().Replace("[::]", System.Net.Dns.GetHostName(), StringComparison.OrdinalIgnoreCase);
|
|
Process.Start(new ProcessStartInfo { FileName = address, UseShellExecute = true });
|
|
}
|
|
}
|
|
|
|
static LogEventLevel GetRequestLoggingLevel(HttpContext context, double elapsedMs, Exception? ex)
|
|
{
|
|
if (context.Request != null && context.Request.Path.HasValue)
|
|
{
|
|
string requestPath = context.Request.Path;
|
|
|
|
if (requestPath.Equals("/Horde.HordeRpc/QueryServerStateV2", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Verbose;
|
|
}
|
|
|
|
if (requestPath.Equals("/Horde.HordeRpc/UpdateSession", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Verbose;
|
|
}
|
|
|
|
if (requestPath.Equals("/Horde.HordeRpc/CreateEvents", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Verbose;
|
|
}
|
|
|
|
if (requestPath.Equals("/Horde.HordeRpc/WriteOutput", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Information;
|
|
}
|
|
|
|
if (requestPath.StartsWith("/Horde.HordeRpc", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Debug;
|
|
}
|
|
|
|
if (requestPath.Equals("/Horde.Relay.RelayRpc/GetPortMappings", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Verbose;
|
|
}
|
|
|
|
if (requestPath.StartsWith("/health", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Debug;
|
|
}
|
|
|
|
if (requestPath.StartsWith("/grpc.health", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Debug;
|
|
}
|
|
|
|
if (requestPath.StartsWith("/api/v1", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Debug;
|
|
}
|
|
|
|
if (requestPath.StartsWith("/ugs/api", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return LogEventLevel.Verbose;
|
|
}
|
|
|
|
if (IsSpaRequest(context))
|
|
{
|
|
return LogEventLevel.Verbose;
|
|
}
|
|
}
|
|
return LogEventLevel.Information;
|
|
}
|
|
|
|
class HostApplicationLifetime : IHostApplicationLifetime
|
|
{
|
|
public CancellationToken ApplicationStarted => CancellationToken.None;
|
|
public CancellationToken ApplicationStopped => CancellationToken.None;
|
|
public CancellationToken ApplicationStopping => CancellationToken.None;
|
|
|
|
public void StopApplication() { }
|
|
}
|
|
|
|
public static void AddServices(IServiceCollection serviceCollection, IConfiguration configuration)
|
|
{
|
|
Startup startup = new Startup(configuration);
|
|
startup.ConfigureServices(serviceCollection);
|
|
serviceCollection.AddSingleton<IHostApplicationLifetime, HostApplicationLifetime>();
|
|
}
|
|
|
|
public static ServiceCollection CreateServiceCollection(IConfiguration configuration)
|
|
{
|
|
ServiceCollection serviceCollection = new ServiceCollection();
|
|
AddServices(serviceCollection, configuration);
|
|
return serviceCollection;
|
|
}
|
|
|
|
public static ServiceCollection CreateServiceCollection(IConfiguration configuration, ILoggerProvider loggerProvider)
|
|
{
|
|
ServiceCollection services = new ServiceCollection();
|
|
services.AddSingleton(configuration);
|
|
services.AddSingleton(loggerProvider);
|
|
AddServices(services, configuration);
|
|
return services;
|
|
}
|
|
}
|
|
}
|