From 2f81ecf5606b528977345b2a28fd81da2fdc445a Mon Sep 17 00:00:00 2001 From: LeoC-SYD Date: Tue, 7 Apr 2026 16:28:01 +0200 Subject: [PATCH 1/2] Add Infomaniak DNS provider Implements IDnsProvider for Infomaniak using their REST API v1 (Bearer token auth). Includes unit tests with a mock HTTP handler and updates the bug report template. Co-Authored-By: Claude Sonnet 4.6 --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .../Acmebot.App.Tests.csproj | 24 ++ .../InfomaniakProviderTests.cs | 271 ++++++++++++++++++ src/Acmebot.App/Acmebot.App.csproj | 6 + src/Acmebot.App/Options/AcmebotOptions.cs | 2 + src/Acmebot.App/Options/InfomaniakOptions.cs | 11 + src/Acmebot.App/Program.cs | 1 + .../Providers/InfomaniakProvider.cs | 140 +++++++++ 8 files changed, 456 insertions(+) create mode 100644 src/Acmebot.App.Tests/Acmebot.App.Tests.csproj create mode 100644 src/Acmebot.App.Tests/InfomaniakProviderTests.cs create mode 100644 src/Acmebot.App/Options/InfomaniakOptions.cs create mode 100644 src/Acmebot.App/Providers/InfomaniakProvider.cs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 78984055..b6cef5e8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -37,6 +37,7 @@ body: - DNS Made Easy - Gandi LiveDNS - GoDaddy + - Infomaniak DNS - Google Cloud DNS - TransIP DNS - Other diff --git a/src/Acmebot.App.Tests/Acmebot.App.Tests.csproj b/src/Acmebot.App.Tests/Acmebot.App.Tests.csproj new file mode 100644 index 00000000..3f96ba38 --- /dev/null +++ b/src/Acmebot.App.Tests/Acmebot.App.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + nullable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/Acmebot.App.Tests/InfomaniakProviderTests.cs b/src/Acmebot.App.Tests/InfomaniakProviderTests.cs new file mode 100644 index 00000000..a45aaa43 --- /dev/null +++ b/src/Acmebot.App.Tests/InfomaniakProviderTests.cs @@ -0,0 +1,271 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +using Acmebot.App.Providers; + +using Xunit; + +namespace Acmebot.App.Tests; + +/// +/// Tests for InfomaniakProvider using a fake HTTP handler — no real domain required. +/// Each test enqueues mock JSON responses matching the Infomaniak REST API v1 format. +/// +public sealed class InfomaniakProviderTests +{ + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /// Builds a fake 200 response with an Infomaniak-style success envelope. + private static HttpResponseMessage OkJson(object data) + { + var payload = JsonSerializer.Serialize(new { result = "success", data }); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + } + + /// Builds a fake 200 response with an empty data array. + private static HttpResponseMessage OkEmpty() => OkJson(Array.Empty()); + + /// Creates a provider backed by the given recording handler. + private static (InfomaniakProvider Provider, RecordingHandler Handler) CreateProvider() + { + var handler = new RecordingHandler(); + var http = new HttpClient(handler) { BaseAddress = new Uri("https://api.infomaniak.com/1/") }; + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token"); + return (new InfomaniakProvider(http), handler); + } + + // ------------------------------------------------------------------------- + // ListZonesAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task ListZonesAsync_ReturnsMappedZones() + { + var (provider, handler) = CreateProvider(); + + handler.Enqueue(_ => OkJson(new[] + { + new { id = 1, customer_name = "example.com" }, + new { id = 2, customer_name = "other.net" } + })); + + var zones = await provider.ListZonesAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, zones.Count); + Assert.Equal("1", zones[0].Id); + Assert.Equal("example.com", zones[0].Name); + Assert.Equal("2", zones[1].Id); + Assert.Equal("other.net", zones[1].Name); + } + + [Fact] + public async Task ListZonesAsync_EmptyResponse_ReturnsEmptyList() + { + var (provider, handler) = CreateProvider(); + handler.Enqueue(_ => OkEmpty()); + + var zones = await provider.ListZonesAsync(TestContext.Current.CancellationToken); + + Assert.Empty(zones); + } + + [Fact] + public async Task ListZonesAsync_RequestHasBearerToken() + { + var (provider, handler) = CreateProvider(); + handler.Enqueue(_ => OkEmpty()); + + await provider.ListZonesAsync(TestContext.Current.CancellationToken); + + var req = Assert.Single(handler.Requests); + Assert.True(req.Headers.TryGetValue("Authorization", out var auth)); + Assert.Equal("Bearer test-token", auth[0]); + } + + // ------------------------------------------------------------------------- + // CreateTxtRecordAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task CreateTxtRecordAsync_SendsOneRequestPerValue() + { + var (provider, handler) = CreateProvider(); + + // Two values → two POST requests + handler.Enqueue(_ => OkJson(new { id = "r1" })); + handler.Enqueue(_ => OkJson(new { id = "r2" })); + + var zone = new DnsZone(provider) { Id = "42", Name = "example.com" }; + + await provider.CreateTxtRecordAsync( + zone, + "_acme-challenge", + ["token-a", "token-b"], + TestContext.Current.CancellationToken); + + Assert.Equal(2, handler.Requests.Count); + Assert.All(handler.Requests, r => Assert.Equal(HttpMethod.Post, r.Method)); + } + + [Fact] + public async Task CreateTxtRecordAsync_PostBodyContainsExpectedFields() + { + var (provider, handler) = CreateProvider(); + handler.Enqueue(_ => OkJson(new { id = "r1" })); + + var zone = new DnsZone(provider) { Id = "42", Name = "example.com" }; + + await provider.CreateTxtRecordAsync( + zone, + "_acme-challenge", + ["my-token"], + TestContext.Current.CancellationToken); + + var req = Assert.Single(handler.Requests); + var body = JsonDocument.Parse(req.Content!); + + Assert.Equal("TXT", body.RootElement.GetProperty("type").GetString()); + Assert.Equal("_acme-challenge", body.RootElement.GetProperty("source").GetString()); + Assert.Equal("my-token", body.RootElement.GetProperty("target").GetString()); + Assert.Equal(60, body.RootElement.GetProperty("ttl").GetInt32()); + } + + // ------------------------------------------------------------------------- + // DeleteTxtRecordAsync + // ------------------------------------------------------------------------- + + [Fact] + public async Task DeleteTxtRecordAsync_DeletesEachRecord() + { + var (provider, handler) = CreateProvider(); + + // First: list records → two results + handler.Enqueue(_ => OkJson(new[] + { + new { id = "r1", source = "_acme-challenge", target = "token-a" }, + new { id = "r2", source = "_acme-challenge", target = "token-b" } + })); + + // Then: two DELETE requests + handler.Enqueue(_ => OkJson(new { })); + handler.Enqueue(_ => OkJson(new { })); + + var zone = new DnsZone(provider) { Id = "42", Name = "example.com" }; + + await provider.DeleteTxtRecordAsync( + zone, + "_acme-challenge", + TestContext.Current.CancellationToken); + + var deletes = handler.Requests.Where(r => r.Method == HttpMethod.Delete).ToList(); + Assert.Equal(2, deletes.Count); + Assert.Contains(deletes, r => r.RequestUri!.ToString().EndsWith("/r1")); + Assert.Contains(deletes, r => r.RequestUri!.ToString().EndsWith("/r2")); + } + + [Fact] + public async Task DeleteTxtRecordAsync_NoRecords_SendsNoDeleteRequest() + { + var (provider, handler) = CreateProvider(); + handler.Enqueue(_ => OkEmpty()); // list returns nothing + + var zone = new DnsZone(provider) { Id = "42", Name = "example.com" }; + + await provider.DeleteTxtRecordAsync( + zone, + "_acme-challenge", + TestContext.Current.CancellationToken); + + Assert.DoesNotContain(handler.Requests, r => r.Method == HttpMethod.Delete); + } + + [Fact] + public async Task DeleteTxtRecordAsync_404OnDelete_IsIgnored() + { + var (provider, handler) = CreateProvider(); + + handler.Enqueue(_ => OkJson(new[] + { + new { id = "r1", source = "_acme-challenge", target = "token-a" } + })); + + // Simulate a 404 — should not throw + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + + var zone = new DnsZone(provider) { Id = "42", Name = "example.com" }; + + await provider.DeleteTxtRecordAsync( + zone, + "_acme-challenge", + TestContext.Current.CancellationToken); + + // If we reach here without exception, the test passes + } + + // ------------------------------------------------------------------------- + // Provider metadata + // ------------------------------------------------------------------------- + + [Fact] + public void Name_IsInfomaniak() + { + var (provider, _) = CreateProvider(); + Assert.Equal("Infomaniak", provider.Name); + } + + [Fact] + public void PropagationDelay_IsPositive() + { + var (provider, _) = CreateProvider(); + Assert.True(provider.PropagationDelay > TimeSpan.Zero); + } +} + +// --------------------------------------------------------------------------- +// Minimal recording handler (mirrors Acmebot.Acme.Tests.RecordingHandler) +// --------------------------------------------------------------------------- + +internal sealed class RecordingHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public List Requests { get; } = []; + + /// Enqueue a response factory that will be dequeued on the next HTTP call. + public void Enqueue(Func factory) + => _responses.Enqueue(factory); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(await RecordedRequest.CreateAsync(request, cancellationToken)); + + return _responses.TryDequeue(out var factory) + ? factory(request) + : throw new InvalidOperationException("No response was configured for this HTTP request."); + } +} + +/// Snapshot of an outgoing HTTP request for assertion purposes. +internal sealed record RecordedRequest( + HttpMethod Method, + Uri? RequestUri, + IReadOnlyDictionary> Headers, + string? Content) +{ + public static async Task CreateAsync(HttpRequestMessage req, CancellationToken ct) => + new( + req.Method, + req.RequestUri, + req.Headers.ToDictionary( + h => h.Key, + h => (IReadOnlyList)h.Value.ToArray(), + StringComparer.OrdinalIgnoreCase), + req.Content is null ? null : await req.Content.ReadAsStringAsync(ct)); +} diff --git a/src/Acmebot.App/Acmebot.App.csproj b/src/Acmebot.App/Acmebot.App.csproj index 9cb2a432..3367c2e9 100644 --- a/src/Acmebot.App/Acmebot.App.csproj +++ b/src/Acmebot.App/Acmebot.App.csproj @@ -36,6 +36,12 @@ + + + <_Parameter1>Acmebot.App.Tests + + + Always diff --git a/src/Acmebot.App/Options/AcmebotOptions.cs b/src/Acmebot.App/Options/AcmebotOptions.cs index 3a3b77b0..4d21cdcf 100644 --- a/src/Acmebot.App/Options/AcmebotOptions.cs +++ b/src/Acmebot.App/Options/AcmebotOptions.cs @@ -50,6 +50,8 @@ public class AcmebotOptions public GoogleDnsOptions? GoogleDns { get; set; } + public InfomaniakOptions? Infomaniak { get; set; } + public IonosDnsOptions? IonosDns { get; set; } public Route53Options? Route53 { get; set; } diff --git a/src/Acmebot.App/Options/InfomaniakOptions.cs b/src/Acmebot.App/Options/InfomaniakOptions.cs new file mode 100644 index 00000000..26e02f41 --- /dev/null +++ b/src/Acmebot.App/Options/InfomaniakOptions.cs @@ -0,0 +1,11 @@ +namespace Acmebot.App.Options; + +/// +/// Configuration options for the Infomaniak DNS provider. +/// Requires an API token generated from the Infomaniak Manager with the "domain" scope. +/// +public class InfomaniakOptions +{ + /// OAuth2 Bearer token with DNS management permissions. + public required string ApiToken { get; set; } +} diff --git a/src/Acmebot.App/Program.cs b/src/Acmebot.App/Program.cs index f50ac9f8..4d61c938 100644 --- a/src/Acmebot.App/Program.cs +++ b/src/Acmebot.App/Program.cs @@ -130,6 +130,7 @@ dnsProviders.TryAdd(options.GandiLiveDns, o => new GandiLiveDnsProvider(o)); dnsProviders.TryAdd(options.GoDaddy, o => new GoDaddyProvider(o)); dnsProviders.TryAdd(options.GoogleDns, o => new GoogleDnsProvider(o)); + dnsProviders.TryAdd(options.Infomaniak, o => new InfomaniakProvider(o)); dnsProviders.TryAdd(options.IonosDns, o => new IonosDnsProvider(o)); dnsProviders.TryAdd(options.Route53, o => new Route53Provider(o)); dnsProviders.TryAdd(options.TransIp, o => new TransIpProvider(options, o, credential)); diff --git a/src/Acmebot.App/Providers/InfomaniakProvider.cs b/src/Acmebot.App/Providers/InfomaniakProvider.cs new file mode 100644 index 00000000..f8d89b89 --- /dev/null +++ b/src/Acmebot.App/Providers/InfomaniakProvider.cs @@ -0,0 +1,140 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +using Acmebot.App.Options; + +namespace Acmebot.App.Providers; + +/// +/// DNS provider for Infomaniak using the REST API v1. +/// Docs: https://developer.infomaniak.com/ +/// Requires an API token with the "domain" scope. +/// +public class InfomaniakProvider : IDnsProvider +{ + public InfomaniakProvider(InfomaniakOptions options) + { + var http = new HttpClient { BaseAddress = new Uri("https://api.infomaniak.com/1/") }; + http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiToken); + _client = new InfomaniakClient(http); + } + + /// Internal constructor for unit tests — inject a pre-configured HttpClient. + internal InfomaniakProvider(HttpClient httpClient) + { + _client = new InfomaniakClient(httpClient); + } + + private readonly InfomaniakClient _client; + + public string Name => "Infomaniak"; + + /// Infomaniak DNS propagation is typically fast. + public TimeSpan PropagationDelay => TimeSpan.FromSeconds(30); + + /// Returns all DNS zones available for the configured token. + public async Task> ListZonesAsync(CancellationToken cancellationToken = default) + { + var zones = await _client.ListZonesAsync(cancellationToken); + + return zones + .Select(z => new DnsZone(this) { Id = z.Id.ToString(), Name = z.CustomerName }) + .ToList(); + } + + /// Creates one TXT record per value for the ACME DNS-01 challenge. + public async Task CreateTxtRecordAsync(DnsZone zone, string relativeRecordName, string[] values, CancellationToken cancellationToken = default) + { + foreach (var value in values) + { + await _client.CreateRecordAsync(zone.Id, relativeRecordName, value, cancellationToken); + } + } + + /// Deletes all TXT records matching the challenge record name. + public async Task DeleteTxtRecordAsync(DnsZone zone, string relativeRecordName, CancellationToken cancellationToken = default) + { + var records = await _client.ListRecordsAsync(zone.Id, relativeRecordName, cancellationToken); + + foreach (var record in records) + { + try + { + await _client.DeleteRecordAsync(zone.Id, record.Id, cancellationToken); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // Record already deleted — safe to ignore + } + } + } + + /// HTTP client wrapper for the Infomaniak DNS API. + private class InfomaniakClient(HttpClient http) + { + private readonly HttpClient _http = http; + + /// GET /1/zone — returns all DNS zones accessible with the token. + public async Task> ListZonesAsync(CancellationToken cancellationToken = default) + { + var response = await _http.GetFromJsonAsync>("zone", cancellationToken); + return response?.Data ?? []; + } + + /// GET /1/zone/{zoneId}/record — returns TXT records matching the given source. + public async Task> ListRecordsAsync(string zoneId, string source, CancellationToken cancellationToken = default) + { + var response = await _http.GetFromJsonAsync>($"zone/{zoneId}/record?type=TXT&source={source}", cancellationToken); + return response?.Data ?? []; + } + + /// POST /1/zone/{zoneId}/record — creates a TXT record for the ACME challenge. + public async Task CreateRecordAsync(string zoneId, string source, string target, CancellationToken cancellationToken = default) + { + var body = new { type = "TXT", source, target, ttl = 60 }; + var response = await _http.PostAsJsonAsync($"zone/{zoneId}/record", body, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + /// DELETE /1/zone/{zoneId}/record/{recordId} — removes a specific DNS record. + public async Task DeleteRecordAsync(string zoneId, string recordId, CancellationToken cancellationToken = default) + { + var response = await _http.DeleteAsync($"zone/{zoneId}/record/{recordId}", cancellationToken); + response.EnsureSuccessStatusCode(); + } + } + + internal class ApiResponse + { + [JsonPropertyName("result")] + public string? Result { get; set; } + + [JsonPropertyName("data")] + public T? Data { get; set; } + } + + internal class Zone + { + [JsonPropertyName("id")] + public required int Id { get; set; } + + /// Domain name as returned by Infomaniak (e.g. "example.com"). + [JsonPropertyName("customer_name")] + public required string CustomerName { get; set; } + } + + internal class Record + { + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("source")] + public required string Source { get; set; } + + [JsonPropertyName("target")] + public required string Target { get; set; } + } +} From 63b33bbb98a70a1309773d635eeeb6cc2524b4cc Mon Sep 17 00:00:00 2001 From: LeoC-SYD Date: Wed, 8 Apr 2026 09:46:35 +0200 Subject: [PATCH 2/2] Add Acmebot.App.Tests/Acmebot.App.Tests.csproj to the solution file --- src/Acmebot.slnx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Acmebot.slnx b/src/Acmebot.slnx index e57ea816..4379eb92 100644 --- a/src/Acmebot.slnx +++ b/src/Acmebot.slnx @@ -2,4 +2,5 @@ +