using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Serialization; using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Model.IO; using MediaBrowser.Model.System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using Morestachio; using Morestachio.Framework.IO.SingleStream; using Morestachio.Rendering; namespace Jellyfin.Server.ServerSetupApp; /// /// Creates a fake application pipeline that will only exist for as long as the main app is not started. /// public sealed class SetupServer : IDisposable { private readonly Func _networkManagerFactory; private readonly IApplicationPaths _applicationPaths; private readonly Func _serverFactory; private readonly ILoggerFactory _loggerFactory; private readonly IConfiguration _startupConfiguration; private readonly ServerConfigurationManager _configurationManager; private IRenderer? _startupUiRenderer; private IHost? _startupServer; private bool _disposed; private bool _isUnhealthy; /// /// Initializes a new instance of the class. /// /// The networkmanager. /// The application paths. /// The servers application host. /// The logger factory. /// The startup configuration. public SetupServer( Func networkManagerFactory, IApplicationPaths applicationPaths, Func serverApplicationHostFactory, ILoggerFactory loggerFactory, IConfiguration startupConfiguration) { _networkManagerFactory = networkManagerFactory; _applicationPaths = applicationPaths; _serverFactory = serverApplicationHostFactory; _loggerFactory = loggerFactory; _startupConfiguration = startupConfiguration; var xmlSerializer = new MyXmlSerializer(); _configurationManager = new ServerConfigurationManager(_applicationPaths, loggerFactory, xmlSerializer); _configurationManager.RegisterConfiguration(); } internal static ConcurrentQueue? LogQueue { get; set; } = new(); /// /// Gets a value indicating whether Startup server is currently running. /// public bool IsAlive { get; internal set; } /// /// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup. /// /// A Task. public async Task RunAsync() { var fileTemplate = await File.ReadAllTextAsync(Path.Combine("ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false); _startupUiRenderer = (await ParserOptionsBuilder.New() .WithTemplate(fileTemplate) .WithFormatter( (StartupLogEntry logEntry, IEnumerable children) => { if (children.Any()) { var maxLevel = logEntry.LogLevel; var stack = new Stack(children); while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) != null) // error is the highest inherted error level. { maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel; foreach (var child in logEntry.Children) { stack.Push(child); } } return maxLevel; } return logEntry.LogLevel; }, "FormatLogLevel") .WithFormatter( (LogLevel logLevel) => { switch (logLevel) { case LogLevel.Trace: case LogLevel.Debug: case LogLevel.None: return "success"; case LogLevel.Information: return "info"; case LogLevel.Warning: return "warn"; case LogLevel.Error: return "danger"; case LogLevel.Critical: return "danger-strong"; } return string.Empty; }, "ToString") .BuildAndParseAsync() .ConfigureAwait(false)) .CreateCompiledRenderer(); ThrowIfDisposed(); var retryAfterValue = TimeSpan.FromSeconds(5); _startupServer = Host.CreateDefaultBuilder() .UseConsoleLifetime() .ConfigureServices(serv => { serv.AddHealthChecks() .AddCheck("StartupCheck"); }) .ConfigureWebHostDefaults(webHostBuilder => { webHostBuilder .UseKestrel((builderContext, options) => { var config = _configurationManager.GetNetworkConfiguration()!; var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger(), config.EnableIPv4, config.EnableIPv6); knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6); var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6); Extensions.WebHostBuilderExtensions.SetupJellyfinWebServer( bindInterfaces, config.InternalHttpPort, null, null, _startupConfiguration, _applicationPaths, _loggerFactory.CreateLogger(), builderContext, options); }) .Configure(app => { app.UseHealthChecks("/health"); app.Map("/startup/logger", loggerRoute => { loggerRoute.Run(async context => { var networkManager = _networkManagerFactory(); if (context.Connection.RemoteIpAddress is null || networkManager is null || !networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress)) { context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; return; } var logFilePath = new DirectoryInfo(_applicationPaths.LogDirectoryPath) .EnumerateFiles() .OrderByDescending(f => f.CreationTimeUtc) .FirstOrDefault() ?.FullName; if (logFilePath is not null) { await context.Response.SendFileAsync(logFilePath, CancellationToken.None).ConfigureAwait(false); } }); }); app.Map("/System/Info/Public", systemRoute => { systemRoute.Run(async context => { var jfApplicationHost = _serverFactory(); var retryCounter = 0; while (jfApplicationHost is null && retryCounter < 5) { await Task.Delay(500).ConfigureAwait(false); jfApplicationHost = _serverFactory(); retryCounter++; } if (jfApplicationHost is null) { context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture)); return; } var sysInfo = new PublicSystemInfo { Version = jfApplicationHost.ApplicationVersionString, ProductName = jfApplicationHost.Name, Id = jfApplicationHost.SystemId, ServerName = jfApplicationHost.FriendlyName, LocalAddress = jfApplicationHost.GetSmartApiUrl(context.Request), StartupWizardCompleted = false }; await context.Response.WriteAsJsonAsync(sysInfo).ConfigureAwait(false); }); }); app.Run(async (context) => { context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable; context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture)); context.Response.Headers.ContentType = new StringValues("text/html"); var networkManager = _networkManagerFactory(); var startupLogEntries = LogQueue?.ToArray() ?? []; await _startupUiRenderer.RenderAsync( new Dictionary() { { "isInReportingMode", _isUnhealthy }, { "retryValue", retryAfterValue }, { "logs", startupLogEntries }, { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } }, new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) .ConfigureAwait(false); }); }); }) .Build(); await _startupServer.StartAsync().ConfigureAwait(false); IsAlive = true; } /// /// Stops the Setup server. /// /// A task. Duh. public async Task StopAsync() { ThrowIfDisposed(); if (_startupServer is null) { throw new InvalidOperationException("Tried to stop a non existing startup server"); } await _startupServer.StopAsync().ConfigureAwait(false); IsAlive = false; } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _startupServer?.Dispose(); IsAlive = false; LogQueue?.Clear(); LogQueue = null; } private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); } internal void SoftStop() { _isUnhealthy = true; } private class SetupHealthcheck : IHealthCheck { private readonly SetupServer _startupServer; public SetupHealthcheck(SetupServer startupServer) { _startupServer = startupServer; } public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { if (_startupServer._isUnhealthy) { return Task.FromResult(HealthCheckResult.Unhealthy("Server is could not complete startup. Check logs.")); } return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up.")); } } internal sealed class SetupLoggerFactory : ILoggerProvider, IDisposable { private bool _disposed; public ILogger CreateLogger(string categoryName) { return new CatchingSetupServerLogger(); } public void Dispose() { if (_disposed) { return; } _disposed = true; } } internal sealed class CatchingSetupServerLogger : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull { return null; } public bool IsEnabled(LogLevel logLevel) { return logLevel is LogLevel.Error or LogLevel.Critical; } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { if (!IsEnabled(logLevel)) { return; } LogQueue?.Enqueue(new() { LogLevel = logLevel, Content = formatter(state, exception), DateOfCreation = DateTimeOffset.Now }); } } internal class StartupLogEntry { public LogLevel LogLevel { get; set; } public string? Content { get; set; } public DateTimeOffset DateOfCreation { get; set; } public List Children { get; set; } = []; } }