From 5697b8f92155379ed6e3a0d19ddda4a74f0869a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Mon, 4 Aug 2025 11:05:11 +0200 Subject: [PATCH] Added 'DNSSEC' implementation --- .gitlab-ci.yml | 6 +- .../Cloudflare.Dns/DnsDnssecExtensions.cs | 59 +++++++ .../InternalEditDnssecStatusRequest.cs | 33 ++++ .../Cloudflare.Dns/Models/DNSSEC.cs | 148 ++++++++++++++++++ src/Extensions/Cloudflare.Dns/README.md | 18 ++- .../Requests/EditDnssecStatusRequest.cs | 76 +++++++++ test/Directory.Build.props | 4 +- .../DeleteDnssecRecordsTest.cs | 82 ++++++++++ .../DnsDnssecExtensions/DnssecDetailsTest.cs | 98 ++++++++++++ .../EditDnssecStatusTest.cs | 114 ++++++++++++++ 10 files changed, 628 insertions(+), 10 deletions(-) create mode 100644 src/Extensions/Cloudflare.Dns/DnsDnssecExtensions.cs create mode 100644 src/Extensions/Cloudflare.Dns/Internals/InternalEditDnssecStatusRequest.cs create mode 100644 src/Extensions/Cloudflare.Dns/Models/DNSSEC.cs create mode 100644 src/Extensions/Cloudflare.Dns/Requests/EditDnssecStatusRequest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DeleteDnssecRecordsTest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DnssecDetailsTest.cs create mode 100644 test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/EditDnssecStatusTest.cs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1eae2d4..a1dc57d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -63,7 +63,7 @@ default-deploy: tags: - docker - lnx - - 64bit + - server rules: - if: $CI_COMMIT_TAG == null script: @@ -122,7 +122,7 @@ core-deploy: tags: - docker - lnx - - 64bit + - server rules: - if: $CI_COMMIT_TAG =~ /^v[0-9.]+/ script: @@ -182,7 +182,7 @@ extensions-deploy: tags: - docker - lnx - - 64bit + - server rules: - if: $CI_COMMIT_TAG =~ /^[a-z]+\/v[0-9.]+/ script: diff --git a/src/Extensions/Cloudflare.Dns/DnsDnssecExtensions.cs b/src/Extensions/Cloudflare.Dns/DnsDnssecExtensions.cs new file mode 100644 index 0000000..2a74f3e --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/DnsDnssecExtensions.cs @@ -0,0 +1,59 @@ +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare.Dns.Internals; + +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Extensions for DNS DNSSEC records. + /// + public static class DnsDnssecExtensions + { + /// + /// Delete DNSSEC. + /// + /// The instance. + /// The zone identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> DeleteDnssecRecords(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + return client.DeleteAsync($"/zones/{zoneId}/dnssec", null, cancellationToken); + } + + /// + /// Enable or disable DNSSEC. + /// + /// The instance. + /// The request. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> EditDnssecStatus(this ICloudflareClient client, EditDnssecStatusRequest request, CancellationToken cancellationToken = default) + { + request.ZoneId.ValidateCloudflareId(); + + var req = new InternalEditDnssecStatusRequest + { + DnssecMultiSigner = request.DnssecMultiSigner, + DnssecPresigned = request.DnssecPresigned, + DnssecUseNsec3 = request.DnssecUseNsec3, + Status = request.Status + }; + + return client.PatchAsync($"/zones/{request.ZoneId}/dnssec", req, cancellationToken); + } + + /// + /// Details about DNSSEC status and configuration. + /// + /// The instance. + /// The zone identifier. + /// A cancellation token used to propagate notification that this operation should be canceled. + public static Task> DnssecDetails(this ICloudflareClient client, string zoneId, CancellationToken cancellationToken = default) + { + zoneId.ValidateCloudflareId(); + + return client.GetAsync($"/zones/{zoneId}/dnssec", null, cancellationToken); + } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Internals/InternalEditDnssecStatusRequest.cs b/src/Extensions/Cloudflare.Dns/Internals/InternalEditDnssecStatusRequest.cs new file mode 100644 index 0000000..74739d7 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Internals/InternalEditDnssecStatusRequest.cs @@ -0,0 +1,33 @@ +namespace AMWD.Net.Api.Cloudflare.Dns.Internals +{ + internal class InternalEditDnssecStatusRequest + { + [JsonProperty("dnssec_multi_signer")] + public bool? DnssecMultiSigner { get; set; } + + /// + /// If , allows Cloudflare to transfer in a DNSSEC-signed zone including signatures from an external provider, without requiring Cloudflare to sign any records on the fly. + /// + /// + /// Note that this feature has some limitations. See Cloudflare as Secondary for details. + /// + [JsonProperty("dnssec_presigned")] + public bool? DnssecPresigned { get; set; } + + /// + /// If , enables the use of NSEC3 together with DNSSEC on the zone. + /// + /// + /// Combined with setting to , this enables the use of NSEC3 records when transferring in from an external provider. + /// If is instead set to (default), NSEC3 records will be generated and signed at request time. + /// + [JsonProperty("dnssec_use_nsec3")] + public bool? DnssecUseNsec3 { get; set; } + + /// + /// Status of DNSSEC, based on user-desired state and presence of necessary records. + /// + [JsonProperty("status")] + public DnssecEditStatus? Status { get; set; } + } +} diff --git a/src/Extensions/Cloudflare.Dns/Models/DNSSEC.cs b/src/Extensions/Cloudflare.Dns/Models/DNSSEC.cs new file mode 100644 index 0000000..b5522d7 --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Models/DNSSEC.cs @@ -0,0 +1,148 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents DNS Security Extensions (DNSSEC) information from Cloudflare. + /// + public class DNSSEC + { + /// + /// Algorithm key code. + /// + [JsonProperty("algorithm")] + public string? Algorithm { get; set; } + + /// + /// Digest hash. + /// + [JsonProperty("digest")] + public string? Digest { get; set; } + + /// + /// Type of digest algorithm. + /// + [JsonProperty("digest_algorithm")] + public string? DigestAlgorithm { get; set; } + + /// + /// Coded type for digest algorithm. + /// + [JsonProperty("digest_type")] + public string? DigestType { get; set; } + + /// + /// If , multi-signer DNSSEC is enabled on the zone, allowing multiple providers to serve a DNSSEC-signed zone at the same time. + /// + /// + /// This is required for DNSKEY records (except those automatically generated by Cloudflare) to be added to the zone. + ///
+ /// See Multi-signer DNSSEC for details. + ///
+ [JsonProperty("dnssec_multi_signer")] + public bool? DnssecMultiSigner { get; set; } + + /// + /// If , allows Cloudflare to transfer in a DNSSEC-signed zone including signatures from an external provider, without requiring Cloudflare to sign any records on the fly. + /// + /// + /// Note that this feature has some limitations. + /// See Cloudflare as Secondary for details. + /// + [JsonProperty("dnssec_presigned")] + public bool? DnssecPresigned { get; set; } + + /// + /// If , enables the use of NSEC3 together with DNSSEC on the zone. + /// + /// + /// Combined with setting to , this enables the use of NSEC3 records when transferring in from an external provider. + /// If is instead set to (default), NSEC3 records will be generated and signed at request time. + ///
+ /// See DNSSEC with NSEC3 for details. + ///
+ [JsonProperty("dnssec_use_nsec3")] + public bool? DnssecUseNsec3 { get; set; } + + /// + /// Full DS record. + /// + [JsonProperty("ds")] + public string? Ds { get; set; } + + /// + /// Flag for DNSSEC record. + /// + [JsonProperty("flags")] + public int? Flags { get; set; } + + /// + /// Code for key tag. + /// + [JsonProperty("key_tag")] + public int? KeyTag { get; set; } + + /// + /// Algorithm key type. + /// + [JsonProperty("key_type")] + public string? KeyType { get; set; } + + /// + /// When DNSSEC was last modified. + /// + [JsonProperty("modified_on")] + public DateTime? ModifiedOn { get; set; } + + /// + /// Public key for DS record. + /// + [JsonProperty("public_key")] + public string? PublicKey { get; set; } + + /// + /// Status of DNSSEC, based on user-desired state and presence of necessary records. + /// + [JsonProperty("status")] + public DNSSECStatus? Status { get; set; } + } + + /// + /// Status of DNSSEC, based on user-desired state and presence of necessary records. + /// Source + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum DNSSECStatus + { + /// + /// Active. + /// + [EnumMember(Value = "active")] + Active = 1, + + /// + /// Pending. + /// + [EnumMember(Value = "pending")] + Pending = 2, + + /// + /// Disabled. + /// + [EnumMember(Value = "disabled")] + Disabled = 3, + + /// + /// Pending disabled. + /// + [EnumMember(Value = "pending-disabled")] + PendingDisabled = 4, + + /// + /// Error. + /// + [EnumMember(Value = "error")] + Error = 5 + } +} diff --git a/src/Extensions/Cloudflare.Dns/README.md b/src/Extensions/Cloudflare.Dns/README.md index d9ccadc..8502d0a 100644 --- a/src/Extensions/Cloudflare.Dns/README.md +++ b/src/Extensions/Cloudflare.Dns/README.md @@ -13,6 +13,13 @@ This package contains the feature set of the _DNS_ section of the Cloudflare API ### [DNS] +### [DNSSEC] + +- [Delete DNSSEC Records](https://developers.cloudflare.com/api/resources/dns/subresources/dnssec/methods/delete/) +- [Edit DNSSEC Status](https://developers.cloudflare.com/api/resources/dns/subresources/dnssec/methods/edit/) +- [DNSSEC Details](https://developers.cloudflare.com/api/resources/dns/subresources/dnssec/methods/get/) + + #### [Records] - [Batch DNS Records](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/batch/) @@ -46,6 +53,7 @@ This package contains the feature set of the _DNS_ section of the Cloudflare API - [Show DNS Settings](https://developers.cloudflare.com/api/resources/dns/subresources/settings/subresources/zone/methods/get/) + --- Published under MIT License (see [choose a license]) @@ -57,8 +65,8 @@ Published under MIT License (see [choose a license]) [Account Custom Nameservers]: https://developers.cloudflare.com/api/resources/custom_nameservers/ [DNS]: https://developers.cloudflare.com/api/resources/dns/ -[Records]: https://developers.cloudflare.com/api/resources/dns/subresources/records/ - -[Settings]: https://developers.cloudflare.com/api/resources/dns/subresources/settings/ -[Account]: https://developers.cloudflare.com/api/resources/dns/subresources/settings/subresources/account/ -[Zone]: https://developers.cloudflare.com/api/resources/dns/subresources/settings/subresources/zone/ + [DNSSEC]: https://developers.cloudflare.com/api/resources/dns/subresources/dnssec/ + [Records]: https://developers.cloudflare.com/api/resources/dns/subresources/records/ + [Settings]: https://developers.cloudflare.com/api/resources/dns/subresources/settings/ + [Account]: https://developers.cloudflare.com/api/resources/dns/subresources/settings/subresources/account/ + [Zone]: https://developers.cloudflare.com/api/resources/dns/subresources/settings/subresources/zone/ diff --git a/src/Extensions/Cloudflare.Dns/Requests/EditDnssecStatusRequest.cs b/src/Extensions/Cloudflare.Dns/Requests/EditDnssecStatusRequest.cs new file mode 100644 index 0000000..67e97fa --- /dev/null +++ b/src/Extensions/Cloudflare.Dns/Requests/EditDnssecStatusRequest.cs @@ -0,0 +1,76 @@ +using System.Runtime.Serialization; +using Newtonsoft.Json.Converters; + +namespace AMWD.Net.Api.Cloudflare.Dns +{ + /// + /// Represents a request to edit the DNSSEC (Domain Name System Security Extensions) status for a domain. + /// + public class EditDnssecStatusRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The zone identifier. + public EditDnssecStatusRequest(string zoneId) + { + ZoneId = zoneId; + } + + /// + /// The zone identifier. + /// + public string ZoneId { get; set; } + + /// + /// If , multi-signer DNSSEC is enabled on the zone, allowing multiple providers to serve a DNSSEC-signed zone at the same time. + /// + /// + /// This is required for DNSKEY records (except those automatically generated by Cloudflare) to be added to the zone. + ///
+ /// See Multi-signer DNSSEC for details. + ///
+ public bool? DnssecMultiSigner { get; set; } + + /// + /// If , allows Cloudflare to transfer in a DNSSEC-signed zone including signatures from an external provider, without requiring Cloudflare to sign any records on the fly. + /// + /// + /// Note that this feature has some limitations. See Cloudflare as Secondary for details. + /// + public bool? DnssecPresigned { get; set; } + + /// + /// If , enables the use of NSEC3 together with DNSSEC on the zone. + /// + /// + /// Combined with setting to , this enables the use of NSEC3 records when transferring in from an external provider. + /// If is instead set to (default), NSEC3 records will be generated and signed at request time. + /// + public bool? DnssecUseNsec3 { get; set; } + + /// + /// Status of DNSSEC, based on user-desired state and presence of necessary records. + /// + public DnssecEditStatus? Status { get; set; } + } + + /// + /// Status of DNSSEC, based on user-desired state and presence of necessary records. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum DnssecEditStatus + { + /// + /// DNSSEC is enabled. + /// + [EnumMember(Value = "active")] + Active = 1, + + /// + /// DNSSEC is disabled. + /// + [EnumMember(Value = "disabled")] + Disabled = 3, + } +} diff --git a/test/Directory.Build.props b/test/Directory.Build.props index ab3c9c4..83860e1 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -13,8 +13,8 @@ - - + + diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DeleteDnssecRecordsTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DeleteDnssecRecordsTest.cs new file mode 100644 index 0000000..39893eb --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DeleteDnssecRecordsTest.cs @@ -0,0 +1,82 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsDnssecExtensions +{ + [TestClass] + public class DeleteDnssecRecordsTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, IQueryParameterFilter? QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = + [ + new ResponseInfo(1000, "Message 1") + ], + Errors = + [ + new ResponseInfo(1000, "Error 1") + ], + Result = "023e105f4ecef8ad9ca31a8372d0c353" + }; + } + + [TestMethod] + public async Task ShouldDeleteDnssecRecords() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DeleteDnssecRecords(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"/zones/{ZoneId}/dnssec", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.DeleteAsync( + $"/zones/{ZoneId}/dnssec", + null, + It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.DeleteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DnssecDetailsTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DnssecDetailsTest.cs new file mode 100644 index 0000000..2518297 --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/DnssecDetailsTest.cs @@ -0,0 +1,98 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsDnssecExtensions +{ + [TestClass] + public class DnssecDetailsTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, IQueryParameterFilter QueryFilter)> _callbacks; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = + [ + new ResponseInfo(1000, "Message 1") + ], + Errors = + [ + new ResponseInfo(1000, "Error 1") + ], + Result = new DNSSEC + { + Algorithm = "ECDSAP256SHA256", + Digest = "1234567890ABCDEF", + DigestAlgorithm = "SHA256", + DigestType = "2", + DnssecMultiSigner = true, + DnssecPresigned = false, + DnssecUseNsec3 = true, + Ds = "12345 13 2 1234567890ABCDEF", + Flags = 257, + KeyTag = 12345, + KeyType = "ECDSAP256SHA256", + ModifiedOn = DateTime.Parse("2025-08-02 10:20:30"), + PublicKey = "ABCDEF1234567890", + Status = DNSSECStatus.Active + } + }; + } + + [TestMethod] + public async Task ShouldGetDnssecDetails() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.DnssecDetails(ZoneId); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"/zones/{ZoneId}/dnssec", callback.RequestPath); + Assert.IsNull(callback.QueryFilter); + + _clientMock.Verify(m => m.GetAsync( + $"/zones/{ZoneId}/dnssec", + null, + It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.GetAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((requestPath, queryFilter, _) => _callbacks.Add((requestPath, queryFilter))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +} diff --git a/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/EditDnssecStatusTest.cs b/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/EditDnssecStatusTest.cs new file mode 100644 index 0000000..f38a753 --- /dev/null +++ b/test/Extensions/Cloudflare.Dns.Tests/DnsDnssecExtensions/EditDnssecStatusTest.cs @@ -0,0 +1,114 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AMWD.Net.Api.Cloudflare; +using AMWD.Net.Api.Cloudflare.Dns; +using AMWD.Net.Api.Cloudflare.Dns.Internals; +using Moq; + +namespace Cloudflare.Dns.Tests.DnsDnssecExtensions +{ + [TestClass] + public class EditDnssecStatusTest + { + private const string ZoneId = "023e105f4ecef8ad9ca31a8372d0c353"; + + private Mock _clientMock; + + private CloudflareResponse _response; + + private List<(string RequestPath, InternalEditDnssecStatusRequest Request)> _callbacks; + + private EditDnssecStatusRequest _request; + + [TestInitialize] + public void Initialize() + { + _callbacks = []; + + _response = new CloudflareResponse + { + Success = true, + Messages = + [ + new ResponseInfo(1000, "Message 1") + ], + Errors = + [ + new ResponseInfo(1000, "Error 1") + ], + Result = new DNSSEC + { + Algorithm = "ECDSAP256SHA256", + Digest = "1234567890ABCDEF", + DigestAlgorithm = "SHA256", + DigestType = "2", + DnssecMultiSigner = true, + DnssecPresigned = false, + DnssecUseNsec3 = true, + Ds = "12345 13 2 1234567890ABCDEF", + Flags = 257, + KeyTag = 12345, + KeyType = "ECDSAP256SHA256", + ModifiedOn = DateTime.UtcNow, + PublicKey = "ABCDEF1234567890", + Status = DNSSECStatus.Active + } + }; + + _request = new EditDnssecStatusRequest(ZoneId) + { + DnssecMultiSigner = true, + DnssecPresigned = false, + DnssecUseNsec3 = true, + Status = DnssecEditStatus.Active + }; + } + + [TestMethod] + public async Task ShouldEditDnssecStatus() + { + // Arrange + var client = GetClient(); + + // Act + var response = await client.EditDnssecStatus(_request); + + // Assert + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); + Assert.AreEqual(_response.Result, response.Result); + + Assert.AreEqual(1, _callbacks.Count); + + var callback = _callbacks.First(); + Assert.AreEqual($"/zones/{ZoneId}/dnssec", callback.RequestPath); + + Assert.IsNotNull(callback.Request); + Assert.AreEqual(_request.DnssecMultiSigner, callback.Request.DnssecMultiSigner); + Assert.AreEqual(_request.DnssecPresigned, callback.Request.DnssecPresigned); + Assert.AreEqual(_request.DnssecUseNsec3, callback.Request.DnssecUseNsec3); + Assert.AreEqual(_request.Status, callback.Request.Status); + + _clientMock.Verify(m => m.PatchAsync( + $"/zones/{ZoneId}/dnssec", + It.IsAny(), + It.IsAny()), Times.Once); + _clientMock.VerifyNoOtherCalls(); + } + + private ICloudflareClient GetClient() + { + _clientMock = new Mock(); + _clientMock + .Setup(m => m.PatchAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((requestPath, request, _) => _callbacks.Add((requestPath, request))) + .ReturnsAsync(() => _response); + + return _clientMock.Object; + } + } +}