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");
+ }
+}