From efc233a6e8bffe64b42400255c16826a4e280caf Mon Sep 17 00:00:00 2001 From: Fichter Tobias Date: Thu, 16 Apr 2026 08:13:16 +0200 Subject: [PATCH 1/4] add regfish-dns provider --- src/Acmebot.App/Options/AcmebotOptions.cs | 2 + src/Acmebot.App/Options/RegfishOptions.cs | 6 + src/Acmebot.App/Program.cs | 1 + src/Acmebot.App/Providers/RegfishProvider.cs | 245 +++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 src/Acmebot.App/Options/RegfishOptions.cs create mode 100644 src/Acmebot.App/Providers/RegfishProvider.cs diff --git a/src/Acmebot.App/Options/AcmebotOptions.cs b/src/Acmebot.App/Options/AcmebotOptions.cs index 3a3b77b0..988aa9b4 100644 --- a/src/Acmebot.App/Options/AcmebotOptions.cs +++ b/src/Acmebot.App/Options/AcmebotOptions.cs @@ -52,6 +52,8 @@ public class AcmebotOptions public IonosDnsOptions? IonosDns { get; set; } + public RegfishOptions? Regfish { get; set; } + public Route53Options? Route53 { get; set; } public TransIpOptions? TransIp { get; set; } diff --git a/src/Acmebot.App/Options/RegfishOptions.cs b/src/Acmebot.App/Options/RegfishOptions.cs new file mode 100644 index 00000000..323a07b1 --- /dev/null +++ b/src/Acmebot.App/Options/RegfishOptions.cs @@ -0,0 +1,6 @@ +namespace Acmebot.App.Options; + +public class RegfishOptions +{ + public required string ApiKey { get; set; } +} diff --git a/src/Acmebot.App/Program.cs b/src/Acmebot.App/Program.cs index f50ac9f8..2105d1f6 100644 --- a/src/Acmebot.App/Program.cs +++ b/src/Acmebot.App/Program.cs @@ -131,6 +131,7 @@ dnsProviders.TryAdd(options.GoDaddy, o => new GoDaddyProvider(o)); dnsProviders.TryAdd(options.GoogleDns, o => new GoogleDnsProvider(o)); dnsProviders.TryAdd(options.IonosDns, o => new IonosDnsProvider(o)); + dnsProviders.TryAdd(options.Regfish, o => new RegfishProvider(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/RegfishProvider.cs b/src/Acmebot.App/Providers/RegfishProvider.cs new file mode 100644 index 00000000..f9112e01 --- /dev/null +++ b/src/Acmebot.App/Providers/RegfishProvider.cs @@ -0,0 +1,245 @@ +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; + +public class RegfishProvider(RegfishOptions options) : IDnsProvider +{ + private readonly RegfishClient _regfishClient = new(options.ApiKey); + + public string Name => "Regfish"; + + public TimeSpan PropagationDelay => TimeSpan.FromSeconds(30); + + public async Task> ListZonesAsync(CancellationToken cancellationToken = default) + { + var zones = await _regfishClient.ListZonesAsync(cancellationToken); + + return zones + .Select(zone => new DnsZone(this) + { + Id = NormalizeName(zone.Domain), + Name = NormalizeName(zone.Domain), + NameServers = zone.DelegationNameServers?.Select(nameServer => NormalizeName(nameServer.Host)).ToArray() ?? [] + }) + .ToArray(); + } + + public async Task CreateTxtRecordAsync(DnsZone zone, string relativeRecordName, string[] values, CancellationToken cancellationToken = default) + { + var recordName = GetAbsoluteRecordName(zone.Name, relativeRecordName); + + foreach (var value in values) + { + var record = new RecordParam + { + Name = recordName, + Type = "TXT", + Data = value, + Ttl = 60 + }; + + await _regfishClient.CreateRecordAsync(record, cancellationToken); + } + } + + public async Task DeleteTxtRecordAsync(DnsZone zone, string relativeRecordName, CancellationToken cancellationToken = default) + { + var absoluteRecordName = GetAbsoluteRecordName(zone.Name, relativeRecordName); + var records = await _regfishClient.ListRecordsAsync(zone.Name, cancellationToken); + + foreach (var record in records) + { + if (!IsMatchingTxtRecord(record, zone.Name, absoluteRecordName)) + { + continue; + } + + try + { + await _regfishClient.DeleteRecordAsync(record.Id, cancellationToken); + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + // ignored + } + } + } + + private static bool IsMatchingTxtRecord(DnsRecord record, string zoneName, string recordName) + { + if (!string.Equals(record.Type, "TXT", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var normalizedRecordName = NormalizeName(recordName); + var normalizedZoneName = NormalizeName(zoneName); + var candidateName = NormalizeName(record.Name); + + if (string.Equals(candidateName, normalizedRecordName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return string.Equals(candidateName, "@", StringComparison.Ordinal) && string.Equals(normalizedRecordName, normalizedZoneName, StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeName(string value) => value.Trim().TrimEnd('.'); + + private static string GetAbsoluteRecordName(string zoneName, string relativeRecordName) => $"{relativeRecordName}.{zoneName}."; + + private class RegfishClient + { + public RegfishClient(string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://api.regfish.com/") + }; + + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-api-key", apiKey); + } + + private readonly HttpClient _httpClient; + + public async Task> ListZonesAsync(CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync("dns/zones", cancellationToken); + var result = await ReadApiResponseAsync(response, "list Regfish zones", cancellationToken: cancellationToken); + + return (result ?? []) + .Where(zone => zone.Active) + .ToArray(); + } + + public async Task> ListRecordsAsync(string domain, CancellationToken cancellationToken = default) + { + using var response = await _httpClient.GetAsync($"dns/{Uri.EscapeDataString(domain)}/rr", cancellationToken); + var result = await ReadApiResponseAsync( + response, + $"list Regfish records for '{domain}'", + HttpStatusCode.InternalServerError, + cancellationToken); + + return result ?? []; + } + + public async Task CreateRecordAsync(RecordParam record, CancellationToken cancellationToken = default) + { + using var response = await _httpClient.PostAsJsonAsync("dns/rr", record, cancellationToken); + await ReadApiResponseAsync(response, $"create Regfish record '{record.Name}'", cancellationToken: cancellationToken); + } + + public async Task DeleteRecordAsync(long recordId, CancellationToken cancellationToken = default) + { + using var response = await _httpClient.DeleteAsync($"dns/rr/{recordId}", cancellationToken); + await ReadApiResponseAsync(response, $"delete Regfish record '{recordId}'", HttpStatusCode.NotFound, cancellationToken); + } + + private static async Task ReadApiResponseAsync(HttpResponseMessage response, string operation, HttpStatusCode? ignoredStatusCode = null, CancellationToken cancellationToken = default) + { + if (ignoredStatusCode == response.StatusCode) + { + return default; + } + + var result = await response.Content.ReadFromJsonAsync>(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var detail = result is null + ? $"HTTP {(int)response.StatusCode} ({response.ReasonPhrase})" + : JoinMessages(result.Message, result.Error); + + throw new HttpRequestException($"Regfish could not {operation}. {detail}".Trim(), null, response.StatusCode); + } + + return EnsureSuccess(result, operation); + } + + private static T EnsureSuccess(ApiResponse? response, string operation) + { + if (response is null) + { + throw new InvalidOperationException($"Regfish returned an empty response while attempting to {operation}."); + } + + if (!response.Success) + { + throw new InvalidOperationException($"Regfish could not {operation}. {JoinMessages(response.Message, response.Error)}".Trim()); + } + + return response.Response; + } + + private static string JoinMessages(params string?[] messages) => string.Join(" ", messages.Where(message => !string.IsNullOrWhiteSpace(message))); + } + + internal class ApiResponse + { + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("error")] + public string? Error { get; set; } + + [JsonPropertyName("response")] + public required T Response { get; set; } + } + + internal class Zone + { + [JsonPropertyName("domain")] + public required string Domain { get; set; } + + [JsonPropertyName("active")] + public bool Active { get; set; } + + [JsonPropertyName("delegation_nameservers")] + public DelegationNameServer[]? DelegationNameServers { get; set; } + } + + internal class DelegationNameServer + { + [JsonPropertyName("host")] + public required string Host { get; set; } + } + + internal class RecordParam + { + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("data")] + public required string Data { get; set; } + + [JsonPropertyName("ttl")] + public int Ttl { get; set; } + } + + internal class DnsRecord + { + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + } +} From 62b2b2451126edb1bd1e62a184716a30f95def77 Mon Sep 17 00:00:00 2001 From: Fichter Tobias Date: Thu, 16 Apr 2026 08:19:46 +0200 Subject: [PATCH 2/4] changed file encoding to match `dotnet format` recommendation --- src/Acmebot.App/Options/RegfishOptions.cs | 2 +- src/Acmebot.App/Providers/RegfishProvider.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Acmebot.App/Options/RegfishOptions.cs b/src/Acmebot.App/Options/RegfishOptions.cs index 323a07b1..7464ae48 100644 --- a/src/Acmebot.App/Options/RegfishOptions.cs +++ b/src/Acmebot.App/Options/RegfishOptions.cs @@ -1,4 +1,4 @@ -namespace Acmebot.App.Options; +namespace Acmebot.App.Options; public class RegfishOptions { diff --git a/src/Acmebot.App/Providers/RegfishProvider.cs b/src/Acmebot.App/Providers/RegfishProvider.cs index f9112e01..44a6da5c 100644 --- a/src/Acmebot.App/Providers/RegfishProvider.cs +++ b/src/Acmebot.App/Providers/RegfishProvider.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Serialization; From 00b648b83d17b74ea28a798a5a91ba9bb15a53c9 Mon Sep 17 00:00:00 2001 From: Fichter Tobias Date: Thu, 16 Apr 2026 09:30:48 +0200 Subject: [PATCH 3/4] remove redundant try/catch --- src/Acmebot.App/Providers/RegfishProvider.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Acmebot.App/Providers/RegfishProvider.cs b/src/Acmebot.App/Providers/RegfishProvider.cs index 44a6da5c..a48853e4 100644 --- a/src/Acmebot.App/Providers/RegfishProvider.cs +++ b/src/Acmebot.App/Providers/RegfishProvider.cs @@ -59,14 +59,7 @@ public async Task DeleteTxtRecordAsync(DnsZone zone, string relativeRecordName, continue; } - try - { - await _regfishClient.DeleteRecordAsync(record.Id, cancellationToken); - } - catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - // ignored - } + await _regfishClient.DeleteRecordAsync(record.Id, cancellationToken); } } From d9abc356338e40ea00dfbdfd7a3e1fe45276fc99 Mon Sep 17 00:00:00 2001 From: Fichter Tobias Date: Thu, 16 Apr 2026 09:31:44 +0200 Subject: [PATCH 4/4] explain HTTP 500 handling --- src/Acmebot.App/Providers/RegfishProvider.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Acmebot.App/Providers/RegfishProvider.cs b/src/Acmebot.App/Providers/RegfishProvider.cs index a48853e4..ea87c709 100644 --- a/src/Acmebot.App/Providers/RegfishProvider.cs +++ b/src/Acmebot.App/Providers/RegfishProvider.cs @@ -116,11 +116,13 @@ public async Task> ListZonesAsync(CancellationToken cancella public async Task> ListRecordsAsync(string domain, CancellationToken cancellationToken = default) { using var response = await _httpClient.GetAsync($"dns/{Uri.EscapeDataString(domain)}/rr", cancellationToken); + // Regfish can return HTTP 500 here for otherwise usable zones. Treat that as an empty + // cleanup result so challenge creation can still proceed. var result = await ReadApiResponseAsync( response, $"list Regfish records for '{domain}'", HttpStatusCode.InternalServerError, - cancellationToken); + cancellationToken: cancellationToken); return result ?? []; }