Browse Source

feat: require api key (#2991)

* feat: require api key

* log scanner api key warnings
pull/2992/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
c27a8d5031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings
  3. 7
      ErsatzTV.Core/Errors/BugsnagConfiguration.cs
  4. 2
      ErsatzTV.Core/FileSystemLayout.cs
  5. 6
      ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs
  6. 9
      ErsatzTV.Core/Security/ApiHelper.cs
  7. 6
      ErsatzTV.Core/Security/ApiSecrets.cs
  8. 93
      ErsatzTV.Scanner/Core/ScannerProxy.cs
  9. 4
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  10. 2
      ErsatzTV/Controllers/InternalController.cs
  11. 25
      ErsatzTV/Filters/ConditionalUiAuthorizeFilter.cs
  12. 5
      ErsatzTV/Program.cs
  13. 50
      ErsatzTV/Security/ApiKeyAuthenticationHandler.cs
  14. 8
      ErsatzTV/Security/ApiKeyAuthenticationOptions.cs
  15. 71
      ErsatzTV/Services/RunOnce/CreateApiKeyService.cs
  16. 88
      ErsatzTV/Startup.cs
  17. 13
      ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json
  18. 13
      ErsatzTV/wwwroot/openapi/scripted-schedule.json
  19. 88
      ErsatzTV/wwwroot/openapi/v1.json
  20. 7
      scripts/scripted-schedules/entrypoint.py

11
CHANGELOG.md

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
# Changelog
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
@ -9,12 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -9,12 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `Re-authenticate with Plex` button to the Plex media sources page
- Use this to replace the credentials ErsatzTV uses (after a Plex password reset, or after signing out of all Plex devices) without removing media sources or synchronized content
- This registers ErsatzTV with Plex as a new device, so Plex issues a new token; signing in again previously returned the same token
- To revoke the old token, remove the old `ErsatzTV` entry from `Authorized Devices` at plex.tv
- To revoke the old token, remove the old `ErsatzTV` entry from `Authorized Devices` at plex.tv and restart your Plex server
### Changed
- Plex servers that are no longer listed at plex.tv are now flagged instead of deleted
- Previously, re-authenticating before re-claiming a server at app.plex.tv would delete that server along with its libraries and all of its media
- Flagged servers are skipped during scans, and are removed only when you choose to remove them
- **BREAKING CHANGE**: require `X-Etv-Api-Key` header for all API requests under `/api`
- The API key is automatically created at startup and can be found in the `api-secrets.json` file in the config folder
- Scripted schedule scripts are passed the API key in the `ETV_API_KEY` environment variable, and must send it in the `X-Etv-Api-Key` header on every call
- The key is not passed as a command line argument, so it does not appear in the process list or in logs
- Scripts that use the bundled docker entrypoint (`/app/scripted-schedules/entrypoint.py`) need no changes
- Hand-written scripts and generated clients must be updated; the API key security scheme is now included in the OpenAPI descriptions
- Troubleshooting playback endpoints are requested directly by the browser, so they authorize with the management UI session instead of the API key
### Fixed
- Fix case where specifically-crafted requests could access management UI over streaming port

1
ErsatzTV.Application/ErsatzTV.Application.csproj.DotSettings

@ -43,6 +43,7 @@ @@ -43,6 +43,7 @@
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=scheduling_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=search_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=security_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Ccommands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>

7
ErsatzTV.Core/Errors/BugsnagConfiguration.cs

@ -1,7 +0,0 @@ @@ -1,7 +0,0 @@
namespace ErsatzTV.Core.Errors;
public class BugsnagConfiguration
{
public string ApiKey { get; set; }
public bool Enable { get; set; }
}

2
ErsatzTV.Core/FileSystemLayout.cs

@ -25,6 +25,7 @@ public static class FileSystemLayout @@ -25,6 +25,7 @@ public static class FileSystemLayout
public static readonly string PlexSecretsPath;
public static readonly string JellyfinSecretsPath;
public static readonly string EmbySecretsPath;
public static readonly string ApiSecretsPath;
public static readonly string FFmpegReportsFolder;
public static readonly string SearchIndexFolder;
@ -153,6 +154,7 @@ public static class FileSystemLayout @@ -153,6 +154,7 @@ public static class FileSystemLayout
PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json");
EmbySecretsPath = Path.Combine(AppDataFolder, "emby-secrets.json");
ApiSecretsPath = Path.Combine(AppDataFolder, "api-secrets.json");
FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");

6
ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.CommandLine.Parsing;
using System.CommandLine.Parsing;
using System.IO.Abstractions;
using CliWrap;
using CliWrap.Buffered;
@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain; @@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Security;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling.ScriptedScheduling;
@ -61,7 +62,7 @@ public class ScriptedPlayoutBuilder( @@ -61,7 +62,7 @@ public class ScriptedPlayoutBuilder(
logger.LogInformation(
"Building scripted playout {Script} with arguments {Arguments}",
scriptFile,
arguments);
scriptArgs);
int daysToBuild = await GetDaysToBuild(cancellationToken);
DateTimeOffset finish = start.AddDays(daysToBuild);
@ -88,6 +89,7 @@ public class ScriptedPlayoutBuilder( @@ -88,6 +89,7 @@ public class ScriptedPlayoutBuilder(
Command command = Cli.Wrap(scriptFile)
.WithArguments(arguments)
.WithEnvironmentVariables(env => env.Set(ApiHelper.EnvironmentVariableName, ApiHelper.ApiKey))
.WithValidation(CommandResultValidation.None);
var commandResult = await command.ExecuteBufferedAsync(linkedCts.Token);

9
ErsatzTV.Core/Security/ApiHelper.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Security;
public static class ApiHelper
{
public const string HeaderName = "X-Etv-Api-Key";
public const string EnvironmentVariableName = "ETV_API_KEY";
public static string ApiKey { get; set; }
}

6
ErsatzTV.Core/Security/ApiSecrets.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Security;
public class ApiSecrets
{
public string ApiKey { get; set; }
}

93
ErsatzTV.Scanner/Core/ScannerProxy.cs

@ -1,15 +1,43 @@ @@ -1,15 +1,43 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using ErsatzTV.Core;
using ErsatzTV.Core.Security;
using ErsatzTV.Scanner.Core.Interfaces;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core;
public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy
public class ScannerProxy(IHttpClientFactory httpClientFactory, ILogger<ScannerProxy> logger) : IScannerProxy
{
private string? _baseUrl;
private bool _loggedKeyRejection;
private Option<ApiSecrets> _secrets;
public void SetBaseUrl(string baseUrl)
{
_baseUrl = baseUrl;
if (!File.Exists(FileSystemLayout.ApiSecretsPath))
{
logger.LogWarning(
"Api secrets file {Path} does not exist; scanner requests will be rejected",
FileSystemLayout.ApiSecretsPath);
return;
}
try
{
string contents = File.ReadAllText(FileSystemLayout.ApiSecretsPath);
_secrets = Optional(JsonSerializer.Deserialize<ApiSecrets>(contents));
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to read api secrets file {Path}; scanner requests will be rejected",
FileSystemLayout.ApiSecretsPath);
}
}
public async Task<bool> UpdateProgress(decimal progress, CancellationToken cancellationToken)
@ -22,13 +50,14 @@ public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy @@ -22,13 +50,14 @@ public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy
try
{
using var httpClient = httpClientFactory.CreateClient();
SetApiKey(httpClient);
var url = $"{_baseUrl}/progress";
await httpClient.PostAsJsonAsync(url, progress, cancellationToken);
return true;
var response = await httpClient.PostAsJsonAsync(url, progress, cancellationToken);
return LogUnsuccessfulResponse(response, "update progress");
}
catch
catch (Exception ex)
{
// do nothing
logger.LogWarning(ex, "Scanner failed to update progress");
}
return false;
@ -49,13 +78,14 @@ public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy @@ -49,13 +78,14 @@ public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy
try
{
using var httpClient = httpClientFactory.CreateClient();
SetApiKey(httpClient);
var url = $"{_baseUrl}/items/reindex";
await httpClient.PostAsJsonAsync(url, mediaItemIds, cancellationToken);
return true;
var response = await httpClient.PostAsJsonAsync(url, mediaItemIds, cancellationToken);
return LogUnsuccessfulResponse(response, "reindex media items");
}
catch
catch (Exception ex)
{
// do nothing
logger.LogWarning(ex, "Scanner failed to reindex media items");
}
return false;
@ -76,13 +106,52 @@ public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy @@ -76,13 +106,52 @@ public class ScannerProxy(IHttpClientFactory httpClientFactory) : IScannerProxy
try
{
using var httpClient = httpClientFactory.CreateClient();
SetApiKey(httpClient);
var url = $"{_baseUrl}/items/remove";
await httpClient.PostAsJsonAsync(url, mediaItemIds, cancellationToken);
var response = await httpClient.PostAsJsonAsync(url, mediaItemIds, cancellationToken);
return LogUnsuccessfulResponse(response, "remove media items");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Scanner failed to remove media items");
}
return false;
}
private void SetApiKey(HttpClient httpClient)
{
foreach (ApiSecrets secrets in _secrets)
{
httpClient.DefaultRequestHeaders.Add(ApiHelper.HeaderName, secrets.ApiKey);
}
}
private bool LogUnsuccessfulResponse(HttpResponseMessage response, string action)
{
if (response.IsSuccessStatusCode)
{
return true;
}
catch
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
// some callers continue after a failure, so warn one time for the whole scan
if (!_loggedKeyRejection)
{
_loggedKeyRejection = true;
logger.LogWarning(
"Scanner failed to {Action}; ErsatzTV rejected the api key from {Path}",
action,
FileSystemLayout.ApiSecretsPath);
}
}
else
{
// do nothing
logger.LogWarning(
"Scanner failed to {Action}; ErsatzTV returned {StatusCode}",
action,
(int)response.StatusCode);
}
return false;

4
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.IO.Abstractions;
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems;
@ -9,6 +9,7 @@ using ErsatzTV.Core.Domain; @@ -9,6 +9,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Serilog.Context;
@ -16,6 +17,7 @@ using Serilog.Context; @@ -16,6 +17,7 @@ using Serilog.Context;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ServiceFilter(typeof(ConditionalUiAuthorizeFilter))]
public class TroubleshootController(
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
IFileSystem fileSystem,

2
ErsatzTV/Controllers/InternalController.cs

@ -28,7 +28,7 @@ namespace ErsatzTV.Controllers; @@ -28,7 +28,7 @@ namespace ErsatzTV.Controllers;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("/internal")]
[Route("internal")]
public class InternalController : StreamingControllerBase
{
private readonly ILogger<InternalController> _logger;

25
ErsatzTV/Filters/ConditionalUiAuthorizeFilter.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ErsatzTV.Filters;
public class ConditionalUiAuthorizeFilter : AuthorizeFilter
{
public ConditionalUiAuthorizeFilter() : base(
new AuthorizationPolicyBuilder().AddAuthenticationSchemes("cookie").RequireAuthenticatedUser().Build())
{
}
public override Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// the browser requests these directly (video player, window.open) so an api key header is not possible;
// authorize with the management ui cookie instead, which only exists when oidc is configured
if (OidcHelper.IsEnabled)
{
return base.OnAuthorizationAsync(context);
}
return Task.CompletedTask;
}
}

5
ErsatzTV/Program.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
@ -103,6 +103,9 @@ public class Program @@ -103,6 +103,9 @@ public class Program
// http
.MinimumLevel.Override("Serilog.AspNetCore.RequestLoggingMiddleware", LoggingLevelSwitches.HttpLevelSwitch)
// api key handler logs debug on every authenticated api request
.MinimumLevel.Override("ErsatzTV.Security", LogEventLevel.Warning)
.Destructure.UsingAttributes()
.Enrich.FromLogContext()
.WriteTo.Sink(InMemoryLogService.Sink)

50
ErsatzTV/Security/ApiKeyAuthenticationHandler.cs

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using ErsatzTV.Core.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace ErsatzTV.Security;
public sealed class ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(ApiHelper.HeaderName, out var providedKey))
{
return Task.FromResult(AuthenticateResult.Fail("Invalid API key"));
}
string expectedKey = ApiHelper.ApiKey;
if (string.IsNullOrEmpty(expectedKey))
{
return Task.FromResult(AuthenticateResult.Fail("API key is not configured"));
}
byte[] providedBytes = Encoding.UTF8.GetBytes(providedKey.ToString());
byte[] expectedBytes = Encoding.UTF8.GetBytes(expectedKey);
if (providedBytes.Length != expectedBytes.Length ||
!CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes))
{
return Task.FromResult(AuthenticateResult.Fail("Invalid API key"));
}
Claim[] claims =
[
new(ClaimTypes.Name, "static-client"),
new("client_id", "static-client")
];
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

8
ErsatzTV/Security/ApiKeyAuthenticationOptions.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Authentication;
namespace ErsatzTV.Security;
public sealed class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "ApiKey";
}

71
ErsatzTV/Services/RunOnce/CreateApiKeyService.cs

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
using System.Security.Cryptography;
using System.Text.Json;
using ErsatzTV.Core;
using ErsatzTV.Core.Security;
namespace ErsatzTV.Services.RunOnce;
public class CreateApiKeyService(ILogger<CreateApiKeyService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
try
{
// first validate secrets
var valid = false;
if (File.Exists(FileSystemLayout.ApiSecretsPath))
{
try
{
string contents = await File.ReadAllTextAsync(FileSystemLayout.ApiSecretsPath, stoppingToken);
Option<ApiSecrets> maybeSecrets = Optional(JsonSerializer.Deserialize<ApiSecrets>(contents));
foreach (var secrets in maybeSecrets)
{
valid = !string.IsNullOrWhiteSpace(secrets.ApiKey);
if (valid)
{
ApiHelper.ApiKey = secrets.ApiKey;
}
}
}
catch (Exception)
{
// do not mark valid
}
if (!valid)
{
logger.LogWarning("Deleting invalid API secrets file");
File.Delete(FileSystemLayout.ApiSecretsPath);
}
}
// generate new secrets if needed
if (!valid)
{
byte[] bytes = RandomNumberGenerator.GetBytes(32);
string base64 = Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace("/", "_")
.Replace("+", "-");
var secrets = new ApiSecrets
{
ApiKey = base64
};
string contents = JsonSerializer.Serialize(secrets);
await File.WriteAllTextAsync(FileSystemLayout.ApiSecretsPath, contents, stoppingToken);
ApiHelper.ApiKey = secrets.ApiKey;
logger.LogInformation("Created new API key");
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to create API key");
}
}
}

88
ErsatzTV/Startup.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO.Abstractions;
using System.Net;
@ -44,6 +44,7 @@ using ErsatzTV.Core.Scheduling.Engine; @@ -44,6 +44,7 @@ using ErsatzTV.Core.Scheduling.Engine;
using ErsatzTV.Core.Scheduling.ScriptedScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Search;
using ErsatzTV.Core.Security;
using ErsatzTV.Core.Trakt;
using ErsatzTV.Core.Troubleshooting;
using ErsatzTV.FFmpeg.Capabilities;
@ -73,6 +74,7 @@ using ErsatzTV.Infrastructure.Sqlite.Data; @@ -73,6 +74,7 @@ using ErsatzTV.Infrastructure.Sqlite.Data;
using ErsatzTV.Infrastructure.Streaming;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Infrastructure.Trakt;
using ErsatzTV.Security;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
using ErsatzTV.Services.RunOnce;
@ -95,6 +97,7 @@ using Microsoft.Extensions.Primitives; @@ -95,6 +97,7 @@ using Microsoft.Extensions.Primitives;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IO;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
using MudBlazor.Services;
using Newtonsoft.Json;
@ -122,8 +125,6 @@ public class Startup @@ -122,8 +125,6 @@ public class Startup
[SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")]
public void ConfigureServices(IServiceCollection services)
{
BugsnagConfiguration bugsnagConfig = Configuration.GetSection("Bugsnag").Get<BugsnagConfiguration>();
services.Configure<BugsnagConfiguration>(Configuration.GetSection("Bugsnag"));
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.All;
@ -134,17 +135,28 @@ public class Startup @@ -134,17 +135,28 @@ public class Startup
services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(FileSystemLayout.DataProtectionFolder));
services.AddOpenApi("v1", options => { options.ShouldInclude += a => a.GroupName == "general"; });
services.AddOpenApi(
"v1",
options =>
{
options.ShouldInclude += a => a.GroupName == "general";
AddApiKeySecurity(options);
});
services.AddOpenApi(
"scripted-schedule-tagged",
options => { options.ShouldInclude += a => a.GroupName == "scripted-schedule"; });
options =>
{
options.ShouldInclude += a => a.GroupName == "scripted-schedule";
AddApiKeySecurity(options);
});
services.AddOpenApi(
"scripted-schedule",
options =>
{
options.ShouldInclude += a => a.GroupName == "scripted-schedule";
AddApiKeySecurity(options);
var tag = new OpenApiTag { Name = "ScriptedSchedule" };
var tagReference = new OpenApiTagReference("ScriptedSchedule");
options.AddOperationTransformer((operation, _, _) =>
@ -161,6 +173,12 @@ public class Startup @@ -161,6 +173,12 @@ public class Startup
});
});
services
.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationOptions.DefaultScheme,
_ => { });
services.ConfigureHttpJsonOptions(o => o.SerializerOptions.NumberHandling = JsonNumberHandling.Strict);
OidcHelper.Init(Configuration);
@ -309,6 +327,7 @@ public class Startup @@ -309,6 +327,7 @@ public class Startup
});
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
services.AddScoped<ConditionalUiAuthorizeFilter>();
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();
@ -647,7 +666,7 @@ public class Startup @@ -647,7 +666,7 @@ public class Startup
app.UseResponseCompression();
app.MapWhen(
ctx => !IsIptvPath(ctx.Request.Path) && !IsInternalPath(ctx.Request.Path),
ctx => !IsIptvPath(ctx.Request.Path) && !IsInternalPath(ctx.Request.Path) && !IsApiPath(ctx.Request.Path),
blazor =>
{
blazor.UseRouting();
@ -686,20 +705,29 @@ public class Startup @@ -686,20 +705,29 @@ public class Startup
});
app.MapWhen(
ctx => IsIptvPath(ctx.Request.Path),
iptv =>
ctx => IsIptvPath(ctx.Request.Path) || IsInternalPath(ctx.Request.Path),
api =>
{
iptv.UseRouting();
iptv.UseEndpoints(endpoints => endpoints.MapControllers());
api.UseRouting();
api.UseEndpoints(endpoints => endpoints.MapControllers());
});
app.MapWhen(
ctx => IsInternalPath(ctx.Request.Path),
internalApp =>
ctx => IsApiPath(ctx.Request.Path),
api =>
{
internalApp.UseRouting();
internalApp.UseEndpoints(endpoints => endpoints.MapControllers());
api.UseRouting();
api.UseAuthentication();
#pragma warning disable ASP0001
api.UseAuthorization();
#pragma warning restore ASP0001
api.UseEndpoints(endpoints => endpoints
.MapControllers()
.RequireAuthorization(
new AuthorizationPolicyBuilder(ApiKeyAuthenticationOptions.DefaultScheme)
.RequireAuthenticatedUser().Build()));
});
return;
bool IsIptvPath(PathString path)
@ -712,8 +740,39 @@ public class Startup @@ -712,8 +740,39 @@ public class Startup
}
bool IsInternalPath(PathString path) => path.StartsWithSegments("/internal");
// troubleshooting endpoints are requested directly by the browser, so they stay on the blazor
// branch and authorize with the ui cookie instead of the api key
bool IsApiPath(PathString path) => path.StartsWithSegments("/api") && !IsTroubleshootPath(path);
bool IsTroubleshootPath(PathString path) => path.StartsWithSegments("/api/troubleshoot");
}
private static void AddApiKeySecurity(OpenApiOptions options) =>
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes[ApiKeyAuthenticationOptions.DefaultScheme] =
new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = ApiHelper.HeaderName,
Description = "API key from api-secrets.json in the ErsatzTV config folder"
};
document.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference(ApiKeyAuthenticationOptions.DefaultScheme, document)] = []
}
];
return Task.CompletedTask;
});
private static void CustomServices(IServiceCollection services)
{
services.AddSingleton<IEnvironmentValidator, EnvironmentValidator>();
@ -880,6 +939,7 @@ public class Startup @@ -880,6 +939,7 @@ public class Startup
services.AddTransient<SlowQueryInterceptor>();
// run-once/blocking startup services
services.AddHostedService<CreateApiKeyService>();
services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>();
services.AddHostedService<DatabaseCleanerService>();

13
ErsatzTV/wwwroot/openapi/scripted-schedule-tagged.json

@ -2214,8 +2214,21 @@ @@ -2214,8 +2214,21 @@
}
}
}
},
"securitySchemes": {
"ApiKey": {
"type": "apiKey",
"description": "API key from api-secrets.json in the ErsatzTV config folder",
"name": "X-Etv-Api-Key",
"in": "header"
}
}
},
"security": [
{
"ApiKey": [ ]
}
],
"tags": [
{
"name": "Scripted Metadata"

13
ErsatzTV/wwwroot/openapi/scripted-schedule.json

@ -2214,8 +2214,21 @@ @@ -2214,8 +2214,21 @@
}
}
}
},
"securitySchemes": {
"ApiKey": {
"type": "apiKey",
"description": "API key from api-secrets.json in the ErsatzTV config folder",
"name": "X-Etv-Api-Key",
"in": "header"
}
}
},
"security": [
{
"ApiKey": [ ]
}
],
"tags": [
{
"name": "ScriptedSchedule"

88
ErsatzTV/wwwroot/openapi/v1.json

@ -314,6 +314,30 @@ @@ -314,6 +314,30 @@
}
}
},
"/api/maintenance/clean_artwork": {
"post": {
"tags": [
"Maintenance"
],
"summary": "Clean artwork cache",
"parameters": [
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 100000
}
}
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/ffmpeg/resolution/by-name/{name}": {
"get": {
"tags": [
@ -659,6 +683,8 @@ @@ -659,6 +683,8 @@
"required": [
"name",
"threadCount",
"normalizeAudio",
"normalizeVideo",
"hardwareAcceleration",
"vaapiDisplay",
"vaapiDriver",
@ -666,6 +692,7 @@ @@ -666,6 +692,7 @@
"qsvExtraHardwareFrames",
"resolutionId",
"scalingBehavior",
"padMode",
"videoFormat",
"videoProfile",
"videoPreset",
@ -678,9 +705,11 @@ @@ -678,9 +705,11 @@
"audioBitrate",
"audioBufferSize",
"normalizeLoudnessMode",
"targetLoudness",
"audioChannels",
"audioSampleRate",
"normalizeFramerate",
"normalizeColors",
"deinterlaceVideo"
],
"type": "object",
@ -695,6 +724,12 @@ @@ -695,6 +724,12 @@
"type": "integer",
"format": "int32"
},
"normalizeAudio": {
"type": "boolean"
},
"normalizeVideo": {
"type": "boolean"
},
"hardwareAcceleration": {
"$ref": "#/components/schemas/HardwareAccelerationKind"
},
@ -727,6 +762,9 @@ @@ -727,6 +762,9 @@
"scalingBehavior": {
"$ref": "#/components/schemas/ScalingBehavior"
},
"padMode": {
"$ref": "#/components/schemas/FilterMode"
},
"videoFormat": {
"$ref": "#/components/schemas/FFmpegProfileVideoFormat"
},
@ -773,6 +811,13 @@ @@ -773,6 +811,13 @@
"normalizeLoudnessMode": {
"$ref": "#/components/schemas/NormalizeLoudnessMode"
},
"targetLoudness": {
"type": [
"null",
"number"
],
"format": "double"
},
"audioChannels": {
"type": "integer",
"format": "int32"
@ -784,6 +829,9 @@ @@ -784,6 +829,9 @@
"normalizeFramerate": {
"type": "boolean"
},
"normalizeColors": {
"type": "boolean"
},
"deinterlaceVideo": {
"type": "boolean"
}
@ -989,6 +1037,9 @@ @@ -989,6 +1037,9 @@
"Copy"
]
},
"FilterMode": {
"type": "integer"
},
"HardwareAccelerationKind": {
"enum": [
"None",
@ -1127,6 +1178,8 @@ @@ -1127,6 +1178,8 @@
"fFmpegProfileId",
"name",
"threadCount",
"normalizeAudio",
"normalizeVideo",
"hardwareAcceleration",
"vaapiDisplay",
"vaapiDriver",
@ -1134,6 +1187,7 @@ @@ -1134,6 +1187,7 @@
"qsvExtraHardwareFrames",
"resolutionId",
"scalingBehavior",
"padMode",
"videoFormat",
"videoProfile",
"videoPreset",
@ -1146,9 +1200,11 @@ @@ -1146,9 +1200,11 @@
"audioBitrate",
"audioBufferSize",
"normalizeLoudnessMode",
"targetLoudness",
"audioChannels",
"audioSampleRate",
"normalizeFramerate",
"normalizeColors",
"deinterlaceVideo"
],
"type": "object",
@ -1167,6 +1223,12 @@ @@ -1167,6 +1223,12 @@
"type": "integer",
"format": "int32"
},
"normalizeAudio": {
"type": "boolean"
},
"normalizeVideo": {
"type": "boolean"
},
"hardwareAcceleration": {
"$ref": "#/components/schemas/HardwareAccelerationKind"
},
@ -1199,6 +1261,9 @@ @@ -1199,6 +1261,9 @@
"scalingBehavior": {
"$ref": "#/components/schemas/ScalingBehavior"
},
"padMode": {
"$ref": "#/components/schemas/FilterMode"
},
"videoFormat": {
"$ref": "#/components/schemas/FFmpegProfileVideoFormat"
},
@ -1245,6 +1310,13 @@ @@ -1245,6 +1310,13 @@
"normalizeLoudnessMode": {
"$ref": "#/components/schemas/NormalizeLoudnessMode"
},
"targetLoudness": {
"type": [
"null",
"number"
],
"format": "double"
},
"audioChannels": {
"type": "integer",
"format": "int32"
@ -1256,6 +1328,9 @@ @@ -1256,6 +1328,9 @@
"normalizeFramerate": {
"type": "boolean"
},
"normalizeColors": {
"type": "boolean"
},
"deinterlaceVideo": {
"type": "boolean"
}
@ -1296,8 +1371,21 @@ @@ -1296,8 +1371,21 @@
"Nouveau"
]
}
},
"securitySchemes": {
"ApiKey": {
"type": "apiKey",
"description": "API key from api-secrets.json in the ErsatzTV config folder",
"name": "X-Etv-Api-Key",
"in": "header"
}
}
},
"security": [
{
"ApiKey": [ ]
}
],
"tags": [
{
"name": "Channel"

7
scripts/scripted-schedules/entrypoint.py

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
import argparse
import importlib
import os
import sys
from uuid import UUID
@ -27,6 +28,12 @@ def main(): @@ -27,6 +28,12 @@ def main():
configuration = etv_client.Configuration(host=known_args.host)
with etv_client.ApiClient(configuration) as api_client:
api_key = os.environ.get("ETV_API_KEY")
if not api_key:
print("Error: ETV_API_KEY is not set; this script must be launched by ErsatzTV.")
sys.exit(1)
api_client.set_default_header("X-Etv-Api-Key", api_key)
try:
define_content = getattr(script_module, 'define_content')
reset_playout = getattr(script_module, 'reset_playout')

Loading…
Cancel
Save