From 88d6d66bcda0f43ac7eb14bc50d9ad15b737edcd Mon Sep 17 00:00:00 2001 From: Tatsuro Shibamura Date: Thu, 30 Jul 2026 20:22:16 +0900 Subject: [PATCH] Send a User-Agent from every DNS provider HTTP client Of the eleven providers that build an HttpClient directly, only UnitedDomains sent a User-Agent, and it sent the bare string "acmebot" with no version. The IONOS API edge rejects requests without a User-Agent with a 503 before authentication is attempted, so zone listing always came back empty and DNS-01 could never complete. Add a shared DnsProviderHttpClient factory that sets the base address, the JSON Accept header, and a User-Agent of Acmebot/{version} matching the one AcmeClientFactory already sends to ACME endpoints. Centralizing the defaults keeps the header from being forgotten by the next provider. Akamai Edge DNS gets its client from EdgeGridSigner, which sets no User-Agent of its own, so the header is applied to that instance afterwards. The TransIP token client inside TransIpSignHandler is covered too. Azure DNS, Azure Private DNS, Route 53 and Google Cloud DNS are unaffected because their SDKs send their own User-Agent. Verified against the live IONOS endpoint with a bogus key: no User-Agent returns 503, Acmebot/5.1.2 reaches authentication and returns 401. Co-Authored-By: Claude Opus 5 --- .../Providers/AkamaiEdgeDnsProvider.cs | 2 + .../Providers/CloudflareProvider.cs | 6 +-- .../Providers/CustomDnsProvider.cs | 7 +-- .../Providers/DnsMadeEasyProvider.cs | 8 +--- .../Providers/DnsProviderHttpClient.cs | 36 +++++++++++++++ .../Providers/GandiLiveDnsProvider.cs | 6 +-- src/Acmebot.App/Providers/GoDaddyProvider.cs | 6 +-- src/Acmebot.App/Providers/IonosDnsProvider.cs | 7 +-- src/Acmebot.App/Providers/OvhProvider.cs | 8 +--- src/Acmebot.App/Providers/PowerDnsProvider.cs | 7 +-- src/Acmebot.App/Providers/RegfishProvider.cs | 7 +-- src/Acmebot.App/Providers/TransIpProvider.cs | 9 +--- .../Providers/UnitedDomainsProvider.cs | 8 +--- .../DnsProviderHttpClientTests.cs | 46 +++++++++++++++++++ 14 files changed, 96 insertions(+), 67 deletions(-) create mode 100644 src/Acmebot.App/Providers/DnsProviderHttpClient.cs create mode 100644 tests/Acmebot.App.Tests/DnsProviderHttpClientTests.cs 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"); + } +}