From e117826c6a5915aaba44bd23fb24f1da998f9a4c Mon Sep 17 00:00:00 2001 From: Jonas F Date: Mon, 13 Jan 2025 18:49:26 +0100 Subject: [PATCH 1/8] Merge master in develop (#55) * Fix reachable and IP leak test (#44) * Fix reachable check Fixes failing reachable checks when Basic Authentication is enabled in Sonarr, Radarr, etc. * Add option to disable IP leak test * Revert "Fix reachable and IP leak test (#44)" (#46) This reverts commit 3f5d7bbef35ed7f480d06214cd40aa1334cf1390. * Release 0.6.1 (#48) * Fix typo * Fix typos * Fix typos * Fix typo * Clarify error message * Fix reachable and ipleak test (#47) * Fix reachable check Fixes failing reachable checks when Basic Authentication is enabled in Sonarr, Radarr, etc. * Add option to disable IP leak test --------- Co-authored-by: Jonas F * Add IpLeakTest environment variable to docker compose --------- Co-authored-by: akuntsch * Create Dockerfile.arm64 --------- Co-authored-by: akuntsch --- Dockerfile.arm64 | 11 ++++ .../Utilities/ServicesExtensions.cs | 2 +- .../GlobalInstanceOptionsValidator.cs | 60 +++++++++---------- docker-compose.yml | 2 +- 4 files changed, 43 insertions(+), 32 deletions(-) create mode 100644 Dockerfile.arm64 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 new file mode 100644 index 0000000..7cacd24 --- /dev/null +++ b/Dockerfile.arm64 @@ -0,0 +1,11 @@ +FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/sdk:8.0 AS build-env +WORKDIR /app + +COPY . ./ +RUN dotnet restore +RUN dotnet publish -c Release -o out + +FROM --platform=linux/arm64 mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build-env /app/out . +ENTRYPOINT ["dotnet", "UmlautAdaptarr.dll"] diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index 594859a..808906a 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -68,7 +68,7 @@ public static class ServicesExtensions Console.WriteLine(($"Property {failure.PropertyName } failed validation. Error was: {failure.ErrorMessage}")); } - throw new Exception("Please fix your environment variables and then Start UmlautAdaptarr again"); + throw new Exception("Please fix cour environment variables and then Start UmlautAdaptarr again"); } var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false); diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index 8c08027..e4c90a8 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -35,38 +35,38 @@ public class GlobalInstanceOptionsValidator : AbstractValidator BeReachable(GlobalInstanceOptions opts, CancellationToken cancellationToken) - { - var endTime = DateTime.Now.AddMinutes(3); - var reachable = false; - var url = $"{opts.Host}/api?apikey={opts.ApiKey}"; + private static async Task BeReachable(GlobalInstanceOptions opts, CancellationToken cancellationToken) + { + var endTime = DateTime.Now.AddMinutes(3); + var reachable = false; + var url = $"{opts.Host}/api?apikey={opts.ApiKey}"; - while (DateTime.Now < endTime) - { - try - { - using var response = await httpClient.GetAsync(url, cancellationToken); - if (response.IsSuccessStatusCode) - { - reachable = true; - break; - } - else - { - Console.WriteLine($"Reachable check got unexpected status code {response.StatusCode}."); - } - } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - } + while (DateTime.Now < endTime) + { + try + { + using var response = await httpClient.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + reachable = true; + break; + } + else + { + Console.WriteLine($"Reachable check got unexpected status code {response.StatusCode}."); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } - // Wait for 15 seconds for next try - Console.WriteLine($"The URL \"{opts.Host}/api?apikey=[REDACTED]\" is not reachable. Next attempt in 15 seconds..."); - Thread.Sleep(15000); - } + // Wait for 15 seconds for next try + Console.WriteLine($"The URL \"{opts.Host}/api?apikey=[REDACTED]\" is not reachable. Next attempt in 15 seconds..."); + Thread.Sleep(15000); + } - return reachable; - } + return reachable; + } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f8944ff..586a1b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: - LIDARR__ENABLED=false - LIDARR__HOST=http://localhost:8686 - LIDARR__APIKEY=APIKEY - #- IpLeakTest_Enabled=false # uncomment and set to true to enable IP leak test + #- IpLeakTest__Enabled=false # uncomment and set to true to enable IP leak test ### example for multiple instances of same type #- SONARR__0__NAME=NAME 1 (optional) #- SONARR__0__ENABLED=false From b6390c15a1ca0fa2ef37d434d8b75464c9461196 Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 19:00:42 +0100 Subject: [PATCH 2/8] Add configurable cache duration --- UmlautAdaptarr/Options/GlobalOptions.cs | 6 ++ UmlautAdaptarr/Program.cs | 16 +++++ .../Services/ProxyRequestService.cs | 2 +- .../Utilities/ServicesExtensions.cs | 2 +- .../GlobalInstanceOptionsValidator.cs | 66 +++++++++---------- docker-compose.yml | 6 +- 6 files changed, 62 insertions(+), 36 deletions(-) diff --git a/UmlautAdaptarr/Options/GlobalOptions.cs b/UmlautAdaptarr/Options/GlobalOptions.cs index 51452a5..f5c6685 100644 --- a/UmlautAdaptarr/Options/GlobalOptions.cs +++ b/UmlautAdaptarr/Options/GlobalOptions.cs @@ -14,5 +14,11 @@ /// The User-Agent string used in HTTP requests. /// public string UserAgent { get; set; } + + /// + /// The duration in minutes to cache the indexer requests. + /// + public int IndexerRequestsCacheDurationInMinutes { get; set; } = 12; + } } \ No newline at end of file diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 5df6506..1cb0fc5 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -22,6 +22,11 @@ internal class Program builder.Services.AddSerilog(); + + // Log all configuration values + LogConfigurationValues(configuration); + + // Add services to the container. builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() => { @@ -118,4 +123,15 @@ internal class Program //.Enrich.With(new ApiKeyMaskingEnricher("appsettings.json")) // TODO - Not working currently .CreateLogger(); } + + private static void LogConfigurationValues(IConfiguration configuration) + { + Log.Information("Logging all configuration values at startup:"); + + foreach (var kvp in configuration.AsEnumerable()) + { + Log.Information("{Key}: {Value}", kvp.Key, kvp.Value); + } + } + } diff --git a/UmlautAdaptarr/Services/ProxyRequestService.cs b/UmlautAdaptarr/Services/ProxyRequestService.cs index 0011244..f136305 100644 --- a/UmlautAdaptarr/Services/ProxyRequestService.cs +++ b/UmlautAdaptarr/Services/ProxyRequestService.cs @@ -81,7 +81,7 @@ namespace UmlautAdaptarr.Services if (responseMessage.IsSuccessStatusCode) { - _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(12)); + _cache.Set(targetUri, responseMessage, TimeSpan.FromMinutes(_options.IndexerRequestsCacheDurationInMinutes)); } return responseMessage; diff --git a/UmlautAdaptarr/Utilities/ServicesExtensions.cs b/UmlautAdaptarr/Utilities/ServicesExtensions.cs index 808906a..594859a 100644 --- a/UmlautAdaptarr/Utilities/ServicesExtensions.cs +++ b/UmlautAdaptarr/Utilities/ServicesExtensions.cs @@ -68,7 +68,7 @@ public static class ServicesExtensions Console.WriteLine(($"Property {failure.PropertyName } failed validation. Error was: {failure.ErrorMessage}")); } - throw new Exception("Please fix cour environment variables and then Start UmlautAdaptarr again"); + throw new Exception("Please fix your environment variables and then Start UmlautAdaptarr again"); } var instanceState = (bool)(typeof(TOptions).GetProperty("Enabled")?.GetValue(option, null) ?? false); diff --git a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs index e4c90a8..cbf7e48 100644 --- a/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs +++ b/UmlautAdaptarr/Validator/GlobalInstanceOptionsValidator.cs @@ -6,7 +6,8 @@ namespace UmlautAdaptarr.Validator; public class GlobalInstanceOptionsValidator : AbstractValidator { - private readonly static HttpClient httpClient = new() { + private readonly static HttpClient httpClient = new() + { Timeout = TimeSpan.FromSeconds(3) }; @@ -22,7 +23,7 @@ public class GlobalInstanceOptionsValidator : AbstractValidator x.ApiKey) .NotEmpty().WithMessage("ApiKey is required when Enabled is true."); - + RuleFor(x => x) .MustAsync(BeReachable) .WithMessage("Host/Url is not reachable. Please check your Host or your UmlautAdaptrr Settings"); @@ -35,38 +36,37 @@ public class GlobalInstanceOptionsValidator : AbstractValidator BeReachable(GlobalInstanceOptions opts, CancellationToken cancellationToken) - { - var endTime = DateTime.Now.AddMinutes(3); - var reachable = false; - var url = $"{opts.Host}/api?apikey={opts.ApiKey}"; + private static async Task BeReachable(GlobalInstanceOptions opts, CancellationToken cancellationToken) + { + var endTime = DateTime.Now.AddMinutes(3); + var reachable = false; + var url = $"{opts.Host}/api?apikey={opts.ApiKey}"; - while (DateTime.Now < endTime) - { - try - { - using var response = await httpClient.GetAsync(url, cancellationToken); - if (response.IsSuccessStatusCode) - { - reachable = true; - break; - } - else - { - Console.WriteLine($"Reachable check got unexpected status code {response.StatusCode}."); - } - } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - } + while (DateTime.Now < endTime) + { + try + { + using var response = await httpClient.GetAsync(url, cancellationToken); + if (response.IsSuccessStatusCode) + { + reachable = true; + break; + } + else + { + Console.WriteLine($"Reachable check got unexpected status code {response.StatusCode}."); + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } - // Wait for 15 seconds for next try - Console.WriteLine($"The URL \"{opts.Host}/api?apikey=[REDACTED]\" is not reachable. Next attempt in 15 seconds..."); - Thread.Sleep(15000); - } - - return reachable; - } + // Wait for 15 seconds for next try + Console.WriteLine($"The URL \"{opts.Host}/api?apikey=[REDACTED]\" is not reachable. Next attempt in 15 seconds..."); + Thread.Sleep(15000); + } + return reachable; + } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 586a1b2..1468a27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,6 @@ services: - LIDARR__ENABLED=false - LIDARR__HOST=http://localhost:8686 - LIDARR__APIKEY=APIKEY - #- IpLeakTest__Enabled=false # uncomment and set to true to enable IP leak test ### example for multiple instances of same type #- SONARR__0__NAME=NAME 1 (optional) #- SONARR__0__ENABLED=false @@ -33,3 +32,8 @@ services: #- SONARR__1__ENABLED=false #- SONARR__1__HOST=http://localhost:8989 #- SONARR__1__APIKEY=APIKEY + + ### Advanced options (with default values)) + #- IpLeakTest__Enabled=false + #- SETTINGS__IndexerRequestsCacheDurationInMinutes=12 + From f916aa37614ada4f923d970aae1e6c540dcf10eb Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 19:35:20 +0100 Subject: [PATCH 3/8] Make proxy port configurable --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1468a27..3787c0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,4 +36,5 @@ services: ### Advanced options (with default values)) #- IpLeakTest__Enabled=false #- SETTINGS__IndexerRequestsCacheDurationInMinutes=12 + #- Kestrel__Endpoints__Http__Url=http://[::]:8080 From 275f29ec1121c1f93b00111feca4ef292c70994d Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 19:35:30 +0100 Subject: [PATCH 4/8] Make proxy port configurable --- UmlautAdaptarr/Options/GlobalOptions.cs | 7 +++++++ UmlautAdaptarr/Program.cs | 19 ++----------------- UmlautAdaptarr/Properties/launchSettings.json | 3 ++- UmlautAdaptarr/Services/HttpProxyService.cs | 11 +++++++---- docker-compose.yml | 8 +++++--- 5 files changed, 23 insertions(+), 25 deletions(-) diff --git a/UmlautAdaptarr/Options/GlobalOptions.cs b/UmlautAdaptarr/Options/GlobalOptions.cs index f5c6685..ee731cc 100644 --- a/UmlautAdaptarr/Options/GlobalOptions.cs +++ b/UmlautAdaptarr/Options/GlobalOptions.cs @@ -20,5 +20,12 @@ /// public int IndexerRequestsCacheDurationInMinutes { get; set; } = 12; + /// + /// API key for requests to the UmlautAdaptarr. Optional. + public string? ApiKey { get; set; } = null; + + /// + /// Proxy port for the internal UmlautAdaptarr proxy. + public int ProxyPort { get; set; } = 5006; } } \ No newline at end of file diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 1cb0fc5..1d2b3bc 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -8,7 +8,8 @@ using UmlautAdaptarr.Utilities; internal class Program { - private static void Main(string[] args) { + private static void Main(string[] args) + { MainAsync(args).Wait(); } @@ -22,11 +23,6 @@ internal class Program builder.Services.AddSerilog(); - - // Log all configuration values - LogConfigurationValues(configuration); - - // Add services to the container. builder.Services.AddHttpClient("HttpClient").ConfigurePrimaryHttpMessageHandler(() => { @@ -123,15 +119,4 @@ internal class Program //.Enrich.With(new ApiKeyMaskingEnricher("appsettings.json")) // TODO - Not working currently .CreateLogger(); } - - private static void LogConfigurationValues(IConfiguration configuration) - { - Log.Information("Logging all configuration values at startup:"); - - foreach (var kvp in configuration.AsEnumerable()) - { - Log.Information("{Key}: {Value}", kvp.Key, kvp.Value); - } - } - } diff --git a/UmlautAdaptarr/Properties/launchSettings.json b/UmlautAdaptarr/Properties/launchSettings.json index 37160f1..1b1f811 100644 --- a/UmlautAdaptarr/Properties/launchSettings.json +++ b/UmlautAdaptarr/Properties/launchSettings.json @@ -3,7 +3,8 @@ "http": { "commandName": "Project", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "Kestrel__Endpoints__Http__Url": "http://[::]:8080" }, "_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", "dotnetRunMessages": true, diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 2017db4..884d6a1 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -1,6 +1,8 @@ -using System.Net; +using Microsoft.Extensions.Options; +using System.Net; using System.Net.Sockets; using System.Text; +using UmlautAdaptarr.Options; namespace UmlautAdaptarr.Services { @@ -8,15 +10,16 @@ namespace UmlautAdaptarr.Services { private TcpListener _listener; private readonly ILogger _logger; - private readonly int _proxyPort = 5006; // TODO move to appsettings.json private readonly IHttpClientFactory _clientFactory; + private readonly GlobalOptions _options; private readonly HashSet _knownHosts = []; private readonly object _hostsLock = new(); private readonly IConfiguration _configuration; private static readonly string[] newLineSeparator = ["\r\n"]; - public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory, IConfiguration configuration) + public HttpProxyService(ILogger logger, IHttpClientFactory clientFactory, IConfiguration configuration, IOptions options) { + _options = options.Value; _logger = logger; _configuration = configuration; _clientFactory = clientFactory; @@ -168,7 +171,7 @@ namespace UmlautAdaptarr.Services public Task StartAsync(CancellationToken cancellationToken) { - _listener = new TcpListener(IPAddress.Any, _proxyPort); + _listener = new TcpListener(IPAddress.Any, _options.ProxyPort); _listener.Start(); Task.Run(() => HandleRequests(cancellationToken), cancellationToken); return Task.CompletedTask; diff --git a/docker-compose.yml b/docker-compose.yml index 3787c0d..ea4bf27 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,8 @@ services: ### Advanced options (with default values)) #- IpLeakTest__Enabled=false - #- SETTINGS__IndexerRequestsCacheDurationInMinutes=12 - #- Kestrel__Endpoints__Http__Url=http://[::]:8080 - + #- SETTINGS__IndexerRequestsCacheDurationInMinutes=12 # How long to cache indexer requests for. Default is 12 minutes. + #- SETTINGS__ApiKey= # API key for requests to the UmlautAdaptarr. Optional, probably only needed for seedboxes. + #- SETTINGS__ProxyPort=5006 # Proxy port for the internal UmlautAdaptarr proxy used for Prowlarr. + #- Kestrel__Endpoints__Http__Url=http://[::]:5005 # HTTP port for the UmlautAdaptarr + From 02a6ec254887c47fc1e224aeb3535438603265d5 Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 21:14:31 +0100 Subject: [PATCH 5/8] Add API Key auth --- UmlautAdaptarr/Controllers/CapsController.cs | 15 +++- .../Controllers/SearchController.cs | 70 +++++++++++++++---- UmlautAdaptarr/Program.cs | 12 ++-- UmlautAdaptarr/Properties/launchSettings.json | 2 +- UmlautAdaptarr/Providers/SonarrClient.cs | 27 ++++++- UmlautAdaptarr/Services/HttpProxyService.cs | 29 +++++++- UmlautAdaptarr/Services/TitleApiService.cs | 65 ++++++++++++++++- UmlautAdaptarr/UmlautAdaptarr.csproj | 10 +-- 8 files changed, 198 insertions(+), 32 deletions(-) diff --git a/UmlautAdaptarr/Controllers/CapsController.cs b/UmlautAdaptarr/Controllers/CapsController.cs index e8df172..1980670 100644 --- a/UmlautAdaptarr/Controllers/CapsController.cs +++ b/UmlautAdaptarr/Controllers/CapsController.cs @@ -1,18 +1,29 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.Text; using System.Xml.Linq; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Controllers { - public class CapsController(ProxyRequestService proxyRequestService) : ControllerBase + public class CapsController(ProxyRequestService proxyRequestService, IOptions options, ILogger logger) : ControllerBase { private readonly ProxyRequestService _proxyRequestService = proxyRequestService; + private readonly GlobalOptions _options = options.Value; + private readonly ILogger _logger = logger; + [HttpGet] - public async Task Caps([FromRoute] string options, [FromRoute] string domain, [FromQuery] string? apikey) + public async Task Caps([FromRoute] string apiKey, [FromRoute] string domain, [FromQuery] string? apikey) { + if (_options.ApiKey != null && !apiKey.Equals(apiKey)) + { + _logger.LogWarning("Invalid or missing API key for request."); + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + if (!domain.StartsWith("localhost") && !UrlUtilities.IsValidDomain(domain)) { return NotFound($"{domain} is not a valid URL."); diff --git a/UmlautAdaptarr/Controllers/SearchController.cs b/UmlautAdaptarr/Controllers/SearchController.cs index 9961616..637a64c 100644 --- a/UmlautAdaptarr/Controllers/SearchController.cs +++ b/UmlautAdaptarr/Controllers/SearchController.cs @@ -1,24 +1,31 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using System.Text; using UmlautAdaptarr.Models; +using UmlautAdaptarr.Options; using UmlautAdaptarr.Providers; using UmlautAdaptarr.Services; using UmlautAdaptarr.Utilities; namespace UmlautAdaptarr.Controllers { - public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, ILogger logger) : ControllerBase + public abstract class SearchControllerBase(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, IOptions options, ILogger logger) : ControllerBase { // TODO evaluate if this should be set to true by default private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false; - protected async Task BaseSearch(string options, + protected async Task BaseSearch(string apiKey, string domain, IDictionary queryParameters, SearchItem? searchItem = null) { try { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + if (!UrlUtilities.IsValidDomain(domain)) { return NotFound($"{domain} is not a valid URL."); @@ -159,30 +166,50 @@ namespace UmlautAdaptarr.Controllers return aggregatedResult; } + + internal bool AssureApiKey(string apiKey) + { + if (options.Value.ApiKey != null && !apiKey.Equals(options.Value.ApiKey)) + { + logger.LogWarning("Invalid or missing API key for request."); + return false; + } + return true; + } } public class SearchController(ProxyRequestService proxyRequestService, TitleMatchingService titleMatchingService, SearchItemLookupService searchItemLookupService, - ILogger logger) : SearchControllerBase(proxyRequestService, titleMatchingService, logger) + IOptions options, + ILogger logger) : SearchControllerBase(proxyRequestService, titleMatchingService, options, logger) { public readonly string[] LIDARR_CATEGORY_IDS = ["3000", "3010", "3020", "3040", "3050"]; public readonly string[] READARR_CATEGORY_IDS = ["3030", "3130", "7000", "7010", "7020", "7030", "7100", "7110", "7120", "7130"]; [HttpGet] - public async Task MovieSearch([FromRoute] string options, [FromRoute] string domain) + public async Task MovieSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } [HttpGet] - public async Task GenericSearch([FromRoute] string options, [FromRoute] string domain) + public async Task GenericSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } - var queryParameters = HttpContext.Request.Query.ToDictionary( + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); @@ -208,21 +235,31 @@ namespace UmlautAdaptarr.Controllers } } - return await BaseSearch(options, domain, queryParameters, searchItem); + return await BaseSearch(apiKey, domain, queryParameters, searchItem); } [HttpGet] - public async Task BookSearch([FromRoute] string options, [FromRoute] string domain) + public async Task BookSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } [HttpGet] - public async Task TVSearch([FromRoute] string options, [FromRoute] string domain) + public async Task TVSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); @@ -239,16 +276,21 @@ namespace UmlautAdaptarr.Controllers searchItem = await searchItemLookupService.GetOrFetchSearchItemByTitle(mediaType, title); } - return await BaseSearch(options, domain, queryParameters, searchItem); + return await BaseSearch(apiKey, domain, queryParameters, searchItem); } [HttpGet] - public async Task MusicSearch([FromRoute] string options, [FromRoute] string domain) + public async Task MusicSearch([FromRoute] string apiKey, [FromRoute] string domain) { + if (!AssureApiKey(apiKey)) + { + return Unauthorized("Unauthorized: Invalid or missing API key."); + } + var queryParameters = HttpContext.Request.Query.ToDictionary( q => q.Key, q => string.Join(",", q.Value)); - return await BaseSearch(options, domain, queryParameters); + return await BaseSearch(apiKey, domain, queryParameters); } } } diff --git a/UmlautAdaptarr/Program.cs b/UmlautAdaptarr/Program.cs index 1d2b3bc..dc871a0 100644 --- a/UmlautAdaptarr/Program.cs +++ b/UmlautAdaptarr/Program.cs @@ -69,32 +69,32 @@ internal class Program app.UseAuthorization(); app.MapControllerRoute("caps", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Caps", action = "Caps" }, new { t = new TRouteConstraint("caps") }); app.MapControllerRoute("movie-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "MovieSearch" }, new { t = new TRouteConstraint("movie") }); app.MapControllerRoute("tv-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "TVSearch" }, new { t = new TRouteConstraint("tvsearch") }); app.MapControllerRoute("music-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "MusicSearch" }, new { t = new TRouteConstraint("music") }); app.MapControllerRoute("book-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "BookSearch" }, new { t = new TRouteConstraint("book") }); app.MapControllerRoute("generic-search", - "{options}/{*domain}", + "{apiKey}/{*domain}", new { controller = "Search", action = "GenericSearch" }, new { t = new TRouteConstraint("search") }); app.Run(); diff --git a/UmlautAdaptarr/Properties/launchSettings.json b/UmlautAdaptarr/Properties/launchSettings.json index 1b1f811..9677901 100644 --- a/UmlautAdaptarr/Properties/launchSettings.json +++ b/UmlautAdaptarr/Properties/launchSettings.json @@ -4,7 +4,7 @@ "commandName": "Project", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", - "Kestrel__Endpoints__Http__Url": "http://[::]:8080" + "SETTINGS__ApiKey": "test123" }, "_launchUrl": "optionsTODO/example.com/api?t=movie&apikey=132&imdbid=123&limit=100", "dotnetRunMessages": true, diff --git a/UmlautAdaptarr/Providers/SonarrClient.cs b/UmlautAdaptarr/Providers/SonarrClient.cs index 9d9b0ad..af45b38 100644 --- a/UmlautAdaptarr/Providers/SonarrClient.cs +++ b/UmlautAdaptarr/Providers/SonarrClient.cs @@ -48,6 +48,21 @@ public class SonarrClient : ArrClientBase if (shows != null) { _logger.LogInformation($"Successfully fetched {shows.Count} items from Sonarr ({InstanceName})."); + // Bulk request (germanTitle, aliases) for all shows + var tvdbIds = new List(); + foreach (var show in shows) + { + if ((string)show.tvdbId is not string tvdbId) + { + continue; + } + tvdbIds.Add(tvdbId); + } + + var bulkTitleData = await _titleService.FetchGermanTitlesAndAliasesByExternalIdBulkAsync(tvdbIds); + string? germanTitle; + string[]? aliases; + foreach (var show in shows) { var tvdbId = (string)show.tvdbId; @@ -57,8 +72,16 @@ public class SonarrClient : ArrClientBase continue; } - var (germanTitle, aliases) = - await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); + if (bulkTitleData.TryGetValue(tvdbId, out var titleData)) + { + (germanTitle, aliases) = titleData; + } + else + { + (germanTitle, aliases) = + await _titleService.FetchGermanTitleAndAliasesByExternalIdAsync(_mediaType, tvdbId); + } + var searchItem = new SearchItem ( (int)show.id, diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 884d6a1..7253905 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -42,6 +42,19 @@ namespace UmlautAdaptarr.Services var bytesRead = await clientStream.ReadAsync(buffer); var requestString = Encoding.ASCII.GetString(buffer, 0, bytesRead); + if (_options.ApiKey != null) + { + var headers = ParseHeaders(buffer, bytesRead); + if (!headers.TryGetValue("Proxy-Authorization", out var proxyAuthorizationHeader) || + !ValidateApiKey(proxyAuthorizationHeader)) + { + _logger.LogWarning("Unauthorized access attempt."); + await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Proxy\"\r\n\r\n")); + clientSocket.Close(); + return; + } + } + if (requestString.StartsWith("CONNECT")) { // Handle HTTPS CONNECT request @@ -53,6 +66,18 @@ namespace UmlautAdaptarr.Services await HandleHttp(requestString, clientStream, clientSocket, buffer, bytesRead); } } + private bool ValidateApiKey(string proxyAuthorizationHeader) + { + // Expect the header to be in the format: "Basic " + if (proxyAuthorizationHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) + { + var encodedKey = proxyAuthorizationHeader["Basic ".Length..].Trim(); + var decodedKey = Encoding.ASCII.GetString(Convert.FromBase64String(encodedKey)); + var password = decodedKey.Split(':')[^1]; + return password == _options.ApiKey; + } + return false; + } private async Task HandleHttpsConnect(string requestString, NetworkStream clientStream, Socket clientSocket) { @@ -99,7 +124,9 @@ namespace UmlautAdaptarr.Services var url = _configuration["Kestrel:Endpoints:Http:Url"]; var port = new Uri(url).Port; - var modifiedUri = $"http://localhost:{port}/_/{uri.Host}{uri.PathAndQuery}"; + var apiKey = _options.ApiKey == null ? "_" : _options.ApiKey; + + var modifiedUri = $"http://localhost:{port}/{apiKey}/{uri.Host}{uri.PathAndQuery}"; using var client = _clientFactory.CreateClient(); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, modifiedUri); httpRequestMessage.Headers.Add("User-Agent", userAgent); diff --git a/UmlautAdaptarr/Services/TitleApiService.cs b/UmlautAdaptarr/Services/TitleApiService.cs index 26733c2..0083f7a 100644 --- a/UmlautAdaptarr/Services/TitleApiService.cs +++ b/UmlautAdaptarr/Services/TitleApiService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Text; using UmlautAdaptarr.Options; using UmlautAdaptarr.Utilities; @@ -22,7 +23,7 @@ namespace UmlautAdaptarr.Services lastRequestTime = DateTime.Now; } - // TODO add cache, TODO add bulk request + // TODO add caching public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) { try @@ -68,6 +69,68 @@ namespace UmlautAdaptarr.Services return (null, null); } + public async Task> FetchGermanTitlesAndAliasesByExternalIdBulkAsync(IEnumerable tvdbIds) + { + try + { + await EnsureMinimumDelayAsync(); + + var httpClient = clientFactory.CreateClient(); + var bulkApiUrl = $"{Options.UmlautAdaptarrApiHost}/tvshow_german.php?bulk=true"; + logger.LogInformation($"TitleApiService POST {UrlUtilities.RedactApiKey(bulkApiUrl)}"); + + // Prepare POST request payload + var payload = new { tvdbIds = tvdbIds.ToArray() }; + var jsonPayload = JsonConvert.SerializeObject(payload); + var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json"); + + // Send POST request + var response = await httpClient.PostAsync(bulkApiUrl, content); + if (!response.IsSuccessStatusCode) + { + logger.LogError($"Failed to fetch German titles via bulk API. Status Code: {response.StatusCode}"); + return []; + } + + var responseContent = await response.Content.ReadAsStringAsync(); + var bulkApiResponseData = JsonConvert.DeserializeObject(responseContent); + + if (bulkApiResponseData == null || bulkApiResponseData.status != "success") + { + logger.LogError($"Parsing UmlautAdaptarr Bulk API response resulted in null or an error status."); + return []; + } + + // Process response data + var results = new Dictionary(); + foreach (var entry in bulkApiResponseData.data) + { + string tvdbId = entry.tvdbId; + string? germanTitle = entry.germanTitle; + + string[]? aliases = null; + if (entry.aliases != null) + { + JArray aliasesArray = JArray.FromObject(entry.aliases); + aliases = aliasesArray.Children() + .Select(alias => alias["name"].ToString()) + .ToArray(); + } + + results[tvdbId] = (germanTitle, aliases); + } + + logger.LogInformation($"Successfully fetched German titles for {results.Count} TVDB IDs via bulk API."); + + return results; + } + catch (Exception ex) + { + logger.LogError($"Error fetching German titles in bulk: {ex.Message}"); + return new Dictionary(); + } + } + public async Task<(string? germanTitle, string? externalId, string[]? aliases)> FetchGermanTitleAndExternalIdAndAliasesByTitle(string mediaType, string title) { try diff --git a/UmlautAdaptarr/UmlautAdaptarr.csproj b/UmlautAdaptarr/UmlautAdaptarr.csproj index 1969466..a60f613 100644 --- a/UmlautAdaptarr/UmlautAdaptarr.csproj +++ b/UmlautAdaptarr/UmlautAdaptarr.csproj @@ -9,13 +9,13 @@ - + - - + + - - + + From dd6b4c9d3b5fff2bead908ac238b9c8a6b15701c Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 21:16:16 +0100 Subject: [PATCH 6/8] Add default settings to appsettings --- UmlautAdaptarr/appsettings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/UmlautAdaptarr/appsettings.json b/UmlautAdaptarr/appsettings.json index 9a94818..92436d1 100644 --- a/UmlautAdaptarr/appsettings.json +++ b/UmlautAdaptarr/appsettings.json @@ -20,7 +20,10 @@ // Settings__UmlautAdaptarrApiHost=https://umlautadaptarr.pcjones.de/api/v1 "Settings": { "UserAgent": "UmlautAdaptarr/1.0", - "UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1" + "UmlautAdaptarrApiHost": "https://umlautadaptarr.pcjones.de/api/v1", + "IndexerRequestsCacheDurationInMinutes": 12, + "ApiKey": null, + "ProxyPort": 5006 }, "Sonarr": [ { From ed044e9a59699618a87543a8ac9d8de63782450f Mon Sep 17 00:00:00 2001 From: pcjones Date: Mon, 13 Jan 2025 21:26:24 +0100 Subject: [PATCH 7/8] Fix too many Unauthorized access attempt warnings --- UmlautAdaptarr/Services/HttpProxyService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/UmlautAdaptarr/Services/HttpProxyService.cs b/UmlautAdaptarr/Services/HttpProxyService.cs index 7253905..1e1c640 100644 --- a/UmlautAdaptarr/Services/HttpProxyService.cs +++ b/UmlautAdaptarr/Services/HttpProxyService.cs @@ -45,10 +45,15 @@ namespace UmlautAdaptarr.Services if (_options.ApiKey != null) { var headers = ParseHeaders(buffer, bytesRead); + if (!headers.TryGetValue("Proxy-Authorization", out var proxyAuthorizationHeader) || !ValidateApiKey(proxyAuthorizationHeader)) { - _logger.LogWarning("Unauthorized access attempt."); + var isFirstRequest = !headers.ContainsKey("Proxy-Authorization"); + if (!isFirstRequest) + { + _logger.LogWarning("Unauthorized access attempt."); + } await clientStream.WriteAsync(Encoding.ASCII.GetBytes("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"Proxy\"\r\n\r\n")); clientSocket.Close(); return; From 3764991e63bacf4ff257903af6f7d987439383fb Mon Sep 17 00:00:00 2001 From: Jonas F Date: Mon, 13 Jan 2025 21:28:34 +0100 Subject: [PATCH 8/8] Merge Develop in master (#57) (#58) * Merge master in develop (#55) * Fix reachable and IP leak test (#44) * Fix reachable check Fixes failing reachable checks when Basic Authentication is enabled in Sonarr, Radarr, etc. * Add option to disable IP leak test * Revert "Fix reachable and IP leak test (#44)" (#46) This reverts commit 3f5d7bbef35ed7f480d06214cd40aa1334cf1390. * Release 0.6.1 (#48) * Fix typo * Fix typos * Fix typos * Fix typo * Clarify error message * Fix reachable and ipleak test (#47) * Fix reachable check Fixes failing reachable checks when Basic Authentication is enabled in Sonarr, Radarr, etc. * Add option to disable IP leak test --------- * Add IpLeakTest environment variable to docker compose --------- * Create Dockerfile.arm64 --------- * Add configurable cache duration * Make proxy port configurable * Make proxy port configurable * Add API Key auth * Add default settings to appsettings * Fix too many Unauthorized access attempt warnings --------- Co-authored-by: akuntsch