diff --git a/src/Acmebot.App/Providers/AkamaiEdgeDnsProvider.cs b/src/Acmebot.App/Providers/AkamaiEdgeDnsProvider.cs index 47439b67..cb60a669 100644 --- a/src/Acmebot.App/Providers/AkamaiEdgeDnsProvider.cs +++ b/src/Acmebot.App/Providers/AkamaiEdgeDnsProvider.cs @@ -65,6 +65,8 @@ public AkamaiEdgeDnsClient(string host, string clientToken, string clientSecret, _httpClient = EdgeGridSigner.CreateHttpClient(new EdgeGridCredentials(host, clientToken, clientSecret, accessToken)); _httpClient.BaseAddress = new Uri($"https://{host}/config-dns/v2/"); + + DnsProviderHttpClient.AddUserAgent(_httpClient); } private readonly HttpClient _httpClient; diff --git a/src/Acmebot.App/Providers/CloudflareProvider.cs b/src/Acmebot.App/Providers/CloudflareProvider.cs index 756883eb..51fe1c80 100644 --- a/src/Acmebot.App/Providers/CloudflareProvider.cs +++ b/src/Acmebot.App/Providers/CloudflareProvider.cs @@ -69,12 +69,8 @@ private class CloudflareClient { public CloudflareClient(string apiToken) { - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://api.cloudflare.com/client/v4/") - }; + _httpClient = DnsProviderHttpClient.Create("https://api.cloudflare.com/client/v4/"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); } diff --git a/src/Acmebot.App/Providers/CustomDnsProvider.cs b/src/Acmebot.App/Providers/CustomDnsProvider.cs index b640d9ee..af322c85 100644 --- a/src/Acmebot.App/Providers/CustomDnsProvider.cs +++ b/src/Acmebot.App/Providers/CustomDnsProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; @@ -60,12 +59,8 @@ private class CustomDnsClient { public CustomDnsClient(string endpoint, string apiKey, string apiKeyHeaderName) { - _httpClient = new HttpClient - { - BaseAddress = new Uri(endpoint) - }; + _httpClient = DnsProviderHttpClient.Create(endpoint); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation(apiKeyHeaderName, apiKey); } diff --git a/src/Acmebot.App/Providers/DnsMadeEasyProvider.cs b/src/Acmebot.App/Providers/DnsMadeEasyProvider.cs index 6ee12900..03c055fe 100644 --- a/src/Acmebot.App/Providers/DnsMadeEasyProvider.cs +++ b/src/Acmebot.App/Providers/DnsMadeEasyProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; @@ -67,12 +66,7 @@ private class DnsMadeEasyClient { public DnsMadeEasyClient(string apiKey, string secretKey) { - _httpClient = new HttpClient(new ApiKeyHandler(apiKey, secretKey)) - { - BaseAddress = new Uri("https://api.dnsmadeeasy.com/V2.0/dns/") - }; - - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient = DnsProviderHttpClient.Create("https://api.dnsmadeeasy.com/V2.0/dns/", new ApiKeyHandler(apiKey, secretKey)); } private readonly HttpClient _httpClient; diff --git a/src/Acmebot.App/Providers/DnsProviderHttpClient.cs b/src/Acmebot.App/Providers/DnsProviderHttpClient.cs new file mode 100644 index 00000000..2715c99d --- /dev/null +++ b/src/Acmebot.App/Providers/DnsProviderHttpClient.cs @@ -0,0 +1,36 @@ +using System.Net.Http.Headers; + +using Acmebot.App.Infrastructure; + +namespace Acmebot.App.Providers; + +/// +/// Creates the instances that DNS providers use to talk to their REST APIs. +/// A User-Agent is required in practice: some provider edges (IONOS is one) reject requests without one +/// with a 5xx before authentication is even attempted, and the resulting failure surfaces only as an +/// empty zone list. Centralizing the defaults here keeps the header from being forgotten by a provider. +/// +internal static class DnsProviderHttpClient +{ + private static readonly ProductInfoHeaderValue s_userAgent = new("Acmebot", Constants.ApplicationVersion); + + public static HttpClient Create(string baseAddress, HttpMessageHandler? handler = null) => Create(new Uri(baseAddress), handler); + + public static HttpClient Create(Uri baseAddress, HttpMessageHandler? handler = null) + { + var httpClient = handler is null ? new HttpClient() : new HttpClient(handler); + + httpClient.BaseAddress = baseAddress; + + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + AddUserAgent(httpClient); + + return httpClient; + } + + /// + /// Applies the User-Agent to a client that was created elsewhere, such as by a provider SDK. + /// + public static void AddUserAgent(HttpClient httpClient) => httpClient.DefaultRequestHeaders.UserAgent.Add(s_userAgent); +} diff --git a/src/Acmebot.App/Providers/GandiLiveDnsProvider.cs b/src/Acmebot.App/Providers/GandiLiveDnsProvider.cs index 18a0a874..d318e2e4 100644 --- a/src/Acmebot.App/Providers/GandiLiveDnsProvider.cs +++ b/src/Acmebot.App/Providers/GandiLiveDnsProvider.cs @@ -56,12 +56,8 @@ private class GandiLiveDnsClient { public GandiLiveDnsClient(string apiKey) { - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://api.gandi.net/v5/") - }; + _httpClient = DnsProviderHttpClient.Create("https://api.gandi.net/v5/"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); } diff --git a/src/Acmebot.App/Providers/GoDaddyProvider.cs b/src/Acmebot.App/Providers/GoDaddyProvider.cs index 46debc4f..1c0549be 100644 --- a/src/Acmebot.App/Providers/GoDaddyProvider.cs +++ b/src/Acmebot.App/Providers/GoDaddyProvider.cs @@ -51,12 +51,8 @@ private class GoDaddyClient { public GoDaddyClient(string apiKey, string apiSecret) { - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://api.godaddy.com/v1/") - }; + _httpClient = DnsProviderHttpClient.Create("https://api.godaddy.com/v1/"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("sso-key", $"{apiKey}:{apiSecret}"); } diff --git a/src/Acmebot.App/Providers/IonosDnsProvider.cs b/src/Acmebot.App/Providers/IonosDnsProvider.cs index 861490d8..771ff2f7 100644 --- a/src/Acmebot.App/Providers/IonosDnsProvider.cs +++ b/src/Acmebot.App/Providers/IonosDnsProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; @@ -69,12 +68,8 @@ private class IonosDnsClient { public IonosDnsClient(string apiKey) { - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://api.hosting.ionos.com/dns/v1/") - }; + _httpClient = DnsProviderHttpClient.Create("https://api.hosting.ionos.com/dns/v1/"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", apiKey); } diff --git a/src/Acmebot.App/Providers/OvhProvider.cs b/src/Acmebot.App/Providers/OvhProvider.cs index df1b5f0c..312a8cfb 100644 --- a/src/Acmebot.App/Providers/OvhProvider.cs +++ b/src/Acmebot.App/Providers/OvhProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; @@ -69,12 +68,7 @@ private class OvhClient public OvhClient(string endpoint, string applicationKey, string applicationSecret, string consumerKey) { // DNS providers in this project own their API clients; this one is constructed once by the singleton provider. - _httpClient = new HttpClient(new ApiKeyHandler(applicationKey, applicationSecret, consumerKey)) - { - BaseAddress = new Uri(NormalizeEndpoint(endpoint)) - }; - - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient = DnsProviderHttpClient.Create(NormalizeEndpoint(endpoint), new ApiKeyHandler(applicationKey, applicationSecret, consumerKey)); } private readonly HttpClient _httpClient; diff --git a/src/Acmebot.App/Providers/PowerDnsProvider.cs b/src/Acmebot.App/Providers/PowerDnsProvider.cs index a5b68a95..9aed80ef 100644 --- a/src/Acmebot.App/Providers/PowerDnsProvider.cs +++ b/src/Acmebot.App/Providers/PowerDnsProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; @@ -70,12 +69,8 @@ public PowerDnsClient(Uri endpoint, string apiKey, string serverId) var baseAddress = endpoint.AbsoluteUri.EndsWith('/') ? endpoint : new Uri(endpoint.AbsoluteUri + "/"); - _httpClient = new HttpClient - { - BaseAddress = baseAddress - }; + _httpClient = DnsProviderHttpClient.Create(baseAddress); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("X-API-Key", apiKey); _serverId = Uri.EscapeDataString(serverId); diff --git a/src/Acmebot.App/Providers/RegfishProvider.cs b/src/Acmebot.App/Providers/RegfishProvider.cs index 173d0e30..754175ce 100644 --- a/src/Acmebot.App/Providers/RegfishProvider.cs +++ b/src/Acmebot.App/Providers/RegfishProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; @@ -90,12 +89,8 @@ public RegfishClient(string apiKey) { ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://api.regfish.com/") - }; + _httpClient = DnsProviderHttpClient.Create("https://api.regfish.com/"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-api-key", apiKey); } diff --git a/src/Acmebot.App/Providers/TransIpProvider.cs b/src/Acmebot.App/Providers/TransIpProvider.cs index cc3cc7d4..a8ad4500 100644 --- a/src/Acmebot.App/Providers/TransIpProvider.cs +++ b/src/Acmebot.App/Providers/TransIpProvider.cs @@ -76,12 +76,7 @@ private class TransIpClient { public TransIpClient(string customerName, CryptographyClient cryptoClient) { - _httpClient = new HttpClient(new TransIpSignHandler(customerName, cryptoClient)) - { - BaseAddress = new Uri("https://api.transip.nl/v6/") - }; - - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient = DnsProviderHttpClient.Create("https://api.transip.nl/v6/", new TransIpSignHandler(customerName, cryptoClient)); } private readonly HttpClient _httpClient; @@ -127,7 +122,7 @@ public async Task CreateRecordAsync(string zoneName, DnsEntry entry, Cancellatio private class TransIpSignHandler(string customerName, CryptographyClient cryptoClient) : DelegatingHandler(new HttpClientHandler()) { - private readonly HttpClient _httpClient = new() { BaseAddress = new Uri("https://api.transip.nl/v6/") }; + private readonly HttpClient _httpClient = DnsProviderHttpClient.Create("https://api.transip.nl/v6/"); private TransIpToken? _token; diff --git a/src/Acmebot.App/Providers/UnitedDomainsProvider.cs b/src/Acmebot.App/Providers/UnitedDomainsProvider.cs index 3d686d45..33e74688 100644 --- a/src/Acmebot.App/Providers/UnitedDomainsProvider.cs +++ b/src/Acmebot.App/Providers/UnitedDomainsProvider.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; @@ -60,14 +59,9 @@ private class UnitedDomainsClient { public UnitedDomainsClient(string apiKey) { - _httpClient = new HttpClient - { - BaseAddress = new Uri("https://dnsapi.united-domains.de/dns/") - }; + _httpClient = DnsProviderHttpClient.Create("https://dnsapi.united-domains.de/dns/"); - _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Add("X-API-Key", apiKey); - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acmebot"); } private readonly HttpClient _httpClient; diff --git a/tests/Acmebot.App.Tests/DnsProviderHttpClientTests.cs b/tests/Acmebot.App.Tests/DnsProviderHttpClientTests.cs new file mode 100644 index 00000000..ce7d35eb --- /dev/null +++ b/tests/Acmebot.App.Tests/DnsProviderHttpClientTests.cs @@ -0,0 +1,46 @@ +using Acmebot.App.Providers; + +using Xunit; + +namespace Acmebot.App.Tests; + +public sealed class DnsProviderHttpClientTests +{ + [Fact] + public void Create_SetsBaseAddressAndJsonAccept() + { + using var httpClient = DnsProviderHttpClient.Create("https://api.example.com/v1/"); + + Assert.Equal(new Uri("https://api.example.com/v1/"), httpClient.BaseAddress); + Assert.Contains(httpClient.DefaultRequestHeaders.Accept, x => x.MediaType == "application/json"); + } + + [Fact] + public void Create_SendsAcmebotUserAgent() + { + using var httpClient = DnsProviderHttpClient.Create("https://api.example.com/v1/"); + + var userAgent = Assert.Single(httpClient.DefaultRequestHeaders.UserAgent); + + Assert.Equal("Acmebot", userAgent.Product?.Name); + Assert.False(string.IsNullOrEmpty(userAgent.Product?.Version)); + } + + [Fact] + public void Create_WithHandler_SendsAcmebotUserAgent() + { + using var httpClient = DnsProviderHttpClient.Create("https://api.example.com/v1/", new HttpClientHandler()); + + Assert.Contains(httpClient.DefaultRequestHeaders.UserAgent, x => x.Product?.Name == "Acmebot"); + } + + [Fact] + public void AddUserAgent_AppliesToClientCreatedElsewhere() + { + using var httpClient = new HttpClient(); + + DnsProviderHttpClient.AddUserAgent(httpClient); + + Assert.Contains(httpClient.DefaultRequestHeaders.UserAgent, x => x.Product?.Name == "Acmebot"); + } +}