From c166526f2151b25ea2d79c38ba21216b20d9e1a8 Mon Sep 17 00:00:00 2001 From: Brian Hill Date: Wed, 19 Aug 2026 11:42:19 -0400 Subject: [PATCH 1/2] Scaffold DigitalOcean DNS-01 domain validator plugin Implements IDomainValidator against the DigitalOcean v2 API: Bearer token auth, zone discovery by longest domain-name suffix match, and TXT record create/delete keyed by DigitalOcean's numeric record id. Modeled on the LuaDNS reference plugin and the win-acme DigitalOcean validator for the proven create/delete call shape. --- .../workflows/keyfactor-starter-workflow.yml | 27 ++ .gitignore | 4 + CHANGELOG.md | 2 + .../DigitalOceanDomainValidatorTests.cs | 45 +++ .../DigitalOceanProviderTests.cs | 259 +++++++++++++++++ .../FakeHttpMessageHandler.cs | 35 +++ ...ctor.DnsProvider.DigitalOcean.Tests.csproj | 19 ++ Keyfactor.DnsProvider.DigitalOcean.slnx | 4 + .../AssemblyInfo.cs | 3 + .../DigitalOceanDomainValidator.cs | 121 ++++++++ .../DigitalOceanProvider.cs | 274 ++++++++++++++++++ .../Keyfactor.DnsProvider.DigitalOcean.csproj | 19 ++ .../manifest.json | 10 + docsource/configuration.md | 48 +++ docsource/content.md | 13 + integration-manifest.json | 36 +++ 16 files changed, 919 insertions(+) create mode 100644 .github/workflows/keyfactor-starter-workflow.yml create mode 100644 CHANGELOG.md create mode 100644 Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs create mode 100644 Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs create mode 100644 Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs create mode 100644 Keyfactor.DnsProvider.DigitalOcean.Tests/Keyfactor.DnsProvider.DigitalOcean.Tests.csproj create mode 100644 Keyfactor.DnsProvider.DigitalOcean.slnx create mode 100644 Keyfactor.DnsProvider.DigitalOcean/AssemblyInfo.cs create mode 100644 Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs create mode 100644 Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs create mode 100644 Keyfactor.DnsProvider.DigitalOcean/Keyfactor.DnsProvider.DigitalOcean.csproj create mode 100644 Keyfactor.DnsProvider.DigitalOcean/manifest.json create mode 100644 docsource/configuration.md create mode 100644 docsource/content.md create mode 100644 integration-manifest.json diff --git a/.github/workflows/keyfactor-starter-workflow.yml b/.github/workflows/keyfactor-starter-workflow.yml new file mode 100644 index 0000000..0f3d3ae --- /dev/null +++ b/.github/workflows/keyfactor-starter-workflow.yml @@ -0,0 +1,27 @@ +name: Keyfactor Bootstrap Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v5 + with: + command_token_url: ${{ vars.COMMAND_TOKEN_URL }} + command_hostname: ${{ vars.COMMAND_HOSTNAME }} + command_base_api_path: ${{ vars.COMMAND_API_PATH }} + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} + scan_token: ${{ secrets.SAST_TOKEN }} + entra_username: ${{ secrets.DOCTOOL_ENTRA_USERNAME }} + entra_password: ${{ secrets.DOCTOOL_ENTRA_PASSWD }} + command_client_id: ${{ secrets.COMMAND_CLIENT_ID }} + command_client_secret: ${{ secrets.COMMAND_CLIENT_SECRET }} diff --git a/.gitignore b/.gitignore index d5a18de..3a9bb62 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,7 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Claude Code / agent state, and local vendor secrets +.claude/ +.secrets/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..78d2335 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +v1.0.0 +- Inital Version diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs new file mode 100644 index 0000000..e7ef93a --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanDomainValidatorTests.cs @@ -0,0 +1,45 @@ +using Xunit; + +namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests +{ + public class DigitalOceanDomainValidatorTests + { + [Fact] + public void GetValidationType_ReturnsDns01() + { + var validator = new DigitalOceanDomainValidator(); + + Assert.Equal("dns-01", validator.GetValidationType()); + } + + [Fact] + public void GetDomainValidatorAnnotations_DeclaresApiToken() + { + var validator = new DigitalOceanDomainValidator(); + + var annotations = validator.GetDomainValidatorAnnotations(); + + Assert.True(annotations.ContainsKey("DigitalOcean_ApiToken")); + Assert.Equal("Secret", annotations["DigitalOcean_ApiToken"].Type); + Assert.True(annotations["DigitalOcean_ApiToken"].Hidden); + } + + [Fact] + public async Task ValidateConfiguration_ThrowsWhenApiTokenMissing() + { + var validator = new DigitalOceanDomainValidator(); + var config = new Dictionary(); + + await Assert.ThrowsAsync(() => validator.ValidateConfiguration(config)); + } + + [Fact] + public async Task ValidateConfiguration_SucceedsWhenApiTokenPresent() + { + var validator = new DigitalOceanDomainValidator(); + var config = new Dictionary { ["DigitalOcean_ApiToken"] = "token" }; + + await validator.ValidateConfiguration(config); + } + } +} diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs new file mode 100644 index 0000000..480e6ba --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/DigitalOceanProviderTests.cs @@ -0,0 +1,259 @@ +using System.Net; +using Xunit; + +namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests +{ + public class DigitalOceanProviderTests + { + [Fact] + public void FindBestMatch_PicksLongestMatchingSuffix() + { + var zones = new[] { "com", "example.com", "other.com" }; + + var match = DigitalOceanProvider.FindBestMatch(zones, "_acme-challenge.www.example.com"); + + Assert.Equal("example.com", match); + } + + [Fact] + public void FindBestMatch_MatchesExactZoneName() + { + var zones = new[] { "example.com" }; + + var match = DigitalOceanProvider.FindBestMatch(zones, "example.com"); + + Assert.Equal("example.com", match); + } + + [Fact] + public void FindBestMatch_ReturnsNullWhenNoZoneMatches() + { + var zones = new[] { "example.com" }; + + var match = DigitalOceanProvider.FindBestMatch(zones, "unrelated-domain.net"); + + Assert.Null(match); + } + + [Fact] + public void FindBestMatch_DoesNotMatchUnrelatedSuffixSubstring() + { + // "notexample.com" must not match zone "example.com" just because it ends with the same characters. + var zones = new[] { "example.com" }; + + var match = DigitalOceanProvider.FindBestMatch(zones, "notexample.com"); + + Assert.Null(match); + } + + [Fact] + public void RelativeRecordName_StripsZoneSuffix() + { + var relative = DigitalOceanProvider.RelativeRecordName("example.com", "_acme-challenge.example.com"); + + Assert.Equal("_acme-challenge", relative); + } + + [Fact] + public void RelativeRecordName_ReturnsApexMarkerForZoneRoot() + { + var relative = DigitalOceanProvider.RelativeRecordName("example.com", "example.com"); + + Assert.Equal("@", relative); + } + + [Fact] + public async Task CreateRecordAsync_PostsTxtRecordToResolvedZone() + { + HttpRequestMessage postRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Post) + { + postRequest = req; + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, + "{\"domain_record\":{\"id\":123,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"); + + Assert.True(result); + Assert.NotNull(postRequest); + Assert.EndsWith("domains/example.com/records", postRequest.RequestUri.PathAndQuery); + + var body = await postRequest.Content.ReadAsStringAsync(); + Assert.Contains("\"name\":\"_acme-challenge\"", body); + Assert.Contains("\"type\":\"TXT\"", body); + Assert.Contains("\"data\":\"abc123\"", body); + } + + [Fact] + public async Task CreateRecordAsync_ThrowsWithApiDetailsWhenApiRejects() + { + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + return FakeHttpMessageHandler.Json(HttpStatusCode.BadRequest, "{\"id\":\"invalid_request\",\"message\":\"invalid data\"}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var ex = await Assert.ThrowsAsync( + () => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT")); + + Assert.Contains("400", ex.Message); + Assert.Contains("example.com", ex.Message); + } + + [Fact] + public async Task CreateRecordAsync_ThrowsWhenNoZoneMatches() + { + var handler = new FakeHttpMessageHandler(req => + FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{\"domains\":[{\"name\":\"other.com\"}],\"links\":{}}")); + + var provider = new DigitalOceanProvider("token", handler); + + var ex = await Assert.ThrowsAsync( + () => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT")); + + Assert.Contains("No DigitalOcean domain found", ex.Message); + } + + [Fact] + public async Task CreateRecordAsync_ThrowsAuthErrorNamingLikelyCauseOn401() + { + var handler = new FakeHttpMessageHandler(req => + FakeHttpMessageHandler.Json(HttpStatusCode.Unauthorized, "{\"id\":\"unauthorized\",\"message\":\"Unable to authenticate you.\"}")); + + var provider = new DigitalOceanProvider("bad-token", handler); + + var ex = await Assert.ThrowsAsync( + () => provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT")); + + Assert.Contains("401", ex.Message); + Assert.Contains("API token", ex.Message); + } + + [Fact] + public async Task CreateRecordAsync_FollowsPaginationToFindZone() + { + var handler = new FakeHttpMessageHandler(req => + { + if (req.RequestUri.PathAndQuery.Contains("page=2")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"other.com\"}],\"links\":{\"pages\":{\"next\":\"https://api.digitalocean.com/v2/domains?page=2&per_page=200\"}}}"); + } + + if (req.Method == HttpMethod.Post) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.Created, + "{\"domain_record\":{\"id\":123,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.CreateRecordAsync("_acme-challenge.example.com", "abc123", "TXT"); + + Assert.True(result); + } + + [Fact] + public async Task DeleteRecordAsync_DeletesMatchingRecord() + { + HttpRequestMessage deleteRequest = null; + + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domain_records\":[{\"id\":7,\"type\":\"TXT\",\"name\":\"_acme-challenge\",\"data\":\"abc123\",\"ttl\":300}]}"); + } + + if (req.Method == HttpMethod.Delete) + { + deleteRequest = req; + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT"); + + Assert.True(result); + Assert.NotNull(deleteRequest); + Assert.EndsWith("domains/example.com/records/7", deleteRequest.RequestUri.PathAndQuery); + } + + [Fact] + public async Task DeleteRecordAsync_IsIdempotentWhenRecordAlreadyGone() + { + var handler = new FakeHttpMessageHandler(req => + { + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/domains?")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, + "{\"domains\":[{\"name\":\"example.com\"}],\"links\":{}}"); + } + + if (req.Method == HttpMethod.Get && req.RequestUri.PathAndQuery.Contains("/records")) + { + return FakeHttpMessageHandler.Json(HttpStatusCode.OK, "{\"domain_records\":[]}"); + } + + throw new InvalidOperationException($"Unexpected request: {req.Method} {req.RequestUri}"); + }); + + var provider = new DigitalOceanProvider("token", handler); + + var result = await provider.DeleteRecordAsync("_acme-challenge.example.com", "TXT"); + + Assert.True(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Constructor_ThrowsOnMissingApiToken(string apiToken) + { + Assert.Throws(() => new DigitalOceanProvider(apiToken, new FakeHttpMessageHandler(_ => + throw new InvalidOperationException("Should not make HTTP calls")))); + } + } +} diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs b/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs new file mode 100644 index 0000000..8d0c8e3 --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/FakeHttpMessageHandler.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http; + +namespace Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests +{ + /// + /// Routes requests to a caller-supplied responder so DigitalOceanProvider can be + /// exercised end-to-end without touching the real DigitalOcean API. + /// + internal class FakeHttpMessageHandler : HttpMessageHandler + { + public List Requests { get; } = new(); + + private readonly Func _responder; + + public FakeHttpMessageHandler(Func responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responder(request)); + } + + public static HttpResponseMessage Json(HttpStatusCode status, string body) + { + return new HttpResponseMessage(status) + { + Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/Keyfactor.DnsProvider.DigitalOcean.Tests/Keyfactor.DnsProvider.DigitalOcean.Tests.csproj b/Keyfactor.DnsProvider.DigitalOcean.Tests/Keyfactor.DnsProvider.DigitalOcean.Tests.csproj new file mode 100644 index 0000000..bf0f88c --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean.Tests/Keyfactor.DnsProvider.DigitalOcean.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + disable + false + Keyfactor.Extensions.DomainValidator.DigitalOcean.Tests + + + + + + + + + + + + diff --git a/Keyfactor.DnsProvider.DigitalOcean.slnx b/Keyfactor.DnsProvider.DigitalOcean.slnx new file mode 100644 index 0000000..2bcb933 --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/Keyfactor.DnsProvider.DigitalOcean/AssemblyInfo.cs b/Keyfactor.DnsProvider.DigitalOcean/AssemblyInfo.cs new file mode 100644 index 0000000..aca4153 --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Keyfactor.DnsProvider.DigitalOcean.Tests")] diff --git a/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs new file mode 100644 index 0000000..fdb6a3f --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanDomainValidator.cs @@ -0,0 +1,121 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 +using Keyfactor.AnyGateway.Extensions; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.DomainValidator.DigitalOcean +{ + /// + /// DigitalOcean domain validator for ACME DNS-01 challenges. Publishes TXT records + /// in DigitalOcean-hosted domains. Authenticates via a Bearer Personal Access Token. + /// + public class DigitalOceanDomainValidator : IDomainValidator + { + private static readonly ILogger _logger = LogHandler.GetClassLogger(); + + private const string ValidationTypeName = "dns-01"; + private const string RecordTypeName = "TXT"; + + private DigitalOceanProvider _provider; + private Dictionary _configuration; + + public Dictionary GetDomainValidatorAnnotations() + { + return new Dictionary() + { + ["DigitalOcean_ApiToken"] = new PropertyConfigInfo() + { + Comments = "DigitalOcean Personal Access Token with domain read/create/delete scopes (Required)", + Hidden = true, + DefaultValue = "", + Type = "Secret" + } + }; + } + + public string GetValidationType() => ValidationTypeName; + + public void Initialize(IDomainValidatorConfigProvider configProvider) + { + _configuration = configProvider.DomainValidationConfiguration; + + var apiToken = GetConfigValue("DigitalOcean_ApiToken"); + + if (string.IsNullOrWhiteSpace(apiToken)) + { + throw new ArgumentException("DigitalOcean_ApiToken is required"); + } + + _provider = new DigitalOceanProvider(apiToken); + } + + public async Task StageValidation(string key, string value, CancellationToken cancellationToken) + { + try + { + var success = await _provider.CreateRecordAsync(key, value, RecordTypeName); + + return new DomainValidationResult + { + Success = success, + ErrorMessage = success ? null : $"Failed to create DNS {RecordTypeName} record for {key}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "DigitalOcean StageValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Failed to create {RecordTypeName} record for {key}: {ex.Message}" + }; + } + } + + public async Task CleanupValidation(string key, CancellationToken cancellationToken) + { + try + { + var success = await _provider.DeleteRecordAsync(key, RecordTypeName); + + return new DomainValidationResult + { + Success = success, + ErrorMessage = success ? null : $"Failed to delete DNS {RecordTypeName} record for {key}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "DigitalOcean CleanupValidation failed for {RecordType} record '{Key}'", RecordTypeName, key); + return new DomainValidationResult + { + Success = false, + ErrorMessage = $"Failed to delete {RecordTypeName} record for {key}: {ex.Message}" + }; + } + } + + public async Task ValidateConfiguration(Dictionary configuration) + { + _configuration = configuration; + + var apiToken = GetConfigValue("DigitalOcean_ApiToken"); + if (string.IsNullOrWhiteSpace(apiToken)) + { + throw new ArgumentException("DigitalOcean_ApiToken is required"); + } + + await Task.CompletedTask; + } + + private string GetConfigValue(string key) + { + if (_configuration != null && _configuration.TryGetValue(key, out var value)) + { + return value?.ToString() ?? string.Empty; + } + return string.Empty; + } + } +} diff --git a/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs new file mode 100644 index 0000000..a0d5d06 --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean/DigitalOceanProvider.cs @@ -0,0 +1,274 @@ +// Copyright 2026 Keyfactor +// Licensed under the Apache License, Version 2.0 +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Keyfactor.Logging; +using Microsoft.Extensions.Logging; + +namespace Keyfactor.Extensions.DomainValidator.DigitalOcean +{ + internal class DigitalOceanProvider + { + private static readonly ILogger _logger = LogHandler.GetClassLogger(); + + private readonly HttpClient _httpClient; + + private class DomainData + { + [JsonPropertyName("name")] + public string Name { get; set; } + } + + private class DomainsResponse + { + [JsonPropertyName("domains")] + public DomainData[] Domains { get; set; } + + [JsonPropertyName("links")] + public LinksData Links { get; set; } + } + + private class LinksData + { + [JsonPropertyName("pages")] + public PagesData Pages { get; set; } + } + + private class PagesData + { + [JsonPropertyName("next")] + public string Next { get; set; } + } + + private class RecordData + { + [JsonPropertyName("id")] + public long Id { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("data")] + public string Data { get; set; } + + [JsonPropertyName("ttl")] + public int Ttl { get; set; } + } + + private class RecordsResponse + { + [JsonPropertyName("domain_records")] + public RecordData[] DomainRecords { get; set; } + } + + private class CreateRecordResponse + { + [JsonPropertyName("domain_record")] + public RecordData DomainRecord { get; set; } + } + + public DigitalOceanProvider(string apiToken) + : this(apiToken, new HttpClientHandler()) + { + } + + // Internal constructor to allow unit tests to inject a fake HttpMessageHandler. + internal DigitalOceanProvider(string apiToken, HttpMessageHandler handler) + { + if (string.IsNullOrWhiteSpace(apiToken)) + { + throw new ArgumentException("apiToken must not be empty", nameof(apiToken)); + } + + _httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.digitalocean.com/v2/") + }; + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + public async Task CreateRecordAsync(string recordName, string value, string recordType) + { + _logger.LogDebug("Creating {RecordType} record for {RecordName}", recordType, recordName); + + var zone = await FindZoneForRecordAsync(recordName); + var relativeName = RelativeRecordName(zone, recordName); + + var payload = new { type = recordType, name = relativeName, data = value, ttl = 300 }; + var json = JsonSerializer.Serialize(payload); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync($"domains/{zone}/records", content); + var result = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "DigitalOcean API rejected creation of {RecordType} record '{RelativeName}' in zone '{Zone}'. Status: {StatusCode}. Response: {Response}", + recordType, relativeName, zone, (int)response.StatusCode, result); + + throw new InvalidOperationException( + $"DigitalOcean API returned {(int)response.StatusCode} ({response.StatusCode}) creating {recordType} record '{relativeName}' in zone '{zone}': {result}"); + } + + _logger.LogInformation( + "Created {RecordType} record '{RelativeName}' in DigitalOcean zone '{Zone}'", + recordType, relativeName, zone); + return true; + } + + public async Task DeleteRecordAsync(string recordName, string recordType) + { + _logger.LogDebug("Deleting {RecordType} record for {RecordName}", recordType, recordName); + + var zone = await FindZoneForRecordAsync(recordName); + var relativeName = RelativeRecordName(zone, recordName); + + var recordsResp = await _httpClient.GetAsync($"domains/{zone}/records?type={recordType}"); + var recordsBody = await recordsResp.Content.ReadAsStringAsync(); + if (!recordsResp.IsSuccessStatusCode) + { + _logger.LogError( + "DigitalOcean API failed to list records for zone '{Zone}'. Status: {StatusCode}. Response: {Response}", + zone, (int)recordsResp.StatusCode, recordsBody); + + throw new InvalidOperationException( + $"DigitalOcean API returned {(int)recordsResp.StatusCode} ({recordsResp.StatusCode}) listing records in zone '{zone}': {recordsBody}"); + } + + var records = JsonSerializer.Deserialize(recordsBody)?.DomainRecords ?? Array.Empty(); + var match = records.FirstOrDefault(r => + string.Equals(r.Type, recordType, StringComparison.OrdinalIgnoreCase) && + string.Equals(r.Name, relativeName, StringComparison.OrdinalIgnoreCase)); + + if (match == null) + { + // Nothing to clean up — treat as success so cleanup is idempotent. + _logger.LogInformation( + "No {RecordType} record '{RelativeName}' found in zone '{Zone}' to delete; treating cleanup as complete", + recordType, relativeName, zone); + return true; + } + + var deleteResp = await _httpClient.DeleteAsync($"domains/{zone}/records/{match.Id}"); + + if (!deleteResp.IsSuccessStatusCode) + { + var deleteBody = await deleteResp.Content.ReadAsStringAsync(); + _logger.LogError( + "DigitalOcean API rejected deletion of {RecordType} record '{RelativeName}' ({RecordId}) in zone '{Zone}'. Status: {StatusCode}. Response: {Response}", + recordType, relativeName, match.Id, zone, (int)deleteResp.StatusCode, deleteBody); + + throw new InvalidOperationException( + $"DigitalOcean API returned {(int)deleteResp.StatusCode} ({deleteResp.StatusCode}) deleting {recordType} record '{relativeName}' in zone '{zone}': {deleteBody}"); + } + + _logger.LogInformation( + "Deleted {RecordType} record '{RelativeName}' in DigitalOcean zone '{Zone}'", + recordType, relativeName, zone); + return true; + } + + /// + /// Fetches all domains (zones) on the account, paging through `links.pages.next`, and + /// resolves the zone that owns the given record by longest matching name suffix, e.g. + /// for "_acme-challenge.www.example.com" it tries "www.example.com", then "example.com". + /// + private async Task FindZoneForRecordAsync(string recordName) + { + if (string.IsNullOrWhiteSpace(recordName)) + { + throw new ArgumentException("Record name must not be empty", nameof(recordName)); + } + + var zoneNames = new List(); + var nextUri = "domains?per_page=200"; + + while (!string.IsNullOrEmpty(nextUri)) + { + var response = await _httpClient.GetAsync(nextUri); + var body = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "DigitalOcean domain list request failed. Status: {StatusCode}. Response: {Response}. " + + "This usually means the API token is invalid, expired, or missing the required scopes.", + (int)response.StatusCode, body); + + throw new InvalidOperationException( + $"DigitalOcean API returned {(int)response.StatusCode} ({response.StatusCode}) while listing domains: {body}. " + + "Verify the configured API token and its scopes."); + } + + var page = JsonSerializer.Deserialize(body); + var domains = page?.Domains ?? Array.Empty(); + zoneNames.AddRange(domains.Where(d => d.Name != null).Select(d => d.Name)); + + var next = page?.Links?.Pages?.Next; + nextUri = string.IsNullOrEmpty(next) ? null : new Uri(next).PathAndQuery.TrimStart('/'); + } + + if (zoneNames.Count == 0) + { + throw new InvalidOperationException("DigitalOcean returned an empty or invalid domains list. Aborting."); + } + + var match = FindBestMatch(zoneNames, recordName.TrimEnd('.')); + + if (match == null) + { + throw new InvalidOperationException( + $"No DigitalOcean domain found for record '{recordName}'. Ensure the domain exists in this DigitalOcean account."); + } + + return match; + } + + /// + /// Computes the record name relative to its owning zone, e.g. for zone "example.com" and + /// record "_acme-challenge.example.com" returns "_acme-challenge"; returns "@" when the + /// record name is the zone apex itself. + /// + internal static string RelativeRecordName(string zone, string recordName) + { + var trimmedRecord = recordName.TrimEnd('.'); + var trimmedZone = zone.TrimEnd('.'); + + if (trimmedRecord.Equals(trimmedZone, StringComparison.OrdinalIgnoreCase)) + { + return "@"; + } + + return trimmedRecord.Substring(0, trimmedRecord.Length - trimmedZone.Length - 1); + } + + /// + /// Finds the zone whose name is the longest suffix match of the target domain, + /// e.g. for domain "_acme-challenge.www.example.com" and zones {"example.com", "com"}, + /// "example.com" wins because it's the more specific (longer) match. + /// + internal static string FindBestMatch(IEnumerable zones, string domain) + { + string best = null; + foreach (var zone in zones) + { + var isMatch = domain.Equals(zone, StringComparison.OrdinalIgnoreCase) || + domain.EndsWith("." + zone, StringComparison.OrdinalIgnoreCase); + + if (isMatch && (best == null || zone.Length > best.Length)) + { + best = zone; + } + } + return best; + } + } +} diff --git a/Keyfactor.DnsProvider.DigitalOcean/Keyfactor.DnsProvider.DigitalOcean.csproj b/Keyfactor.DnsProvider.DigitalOcean/Keyfactor.DnsProvider.DigitalOcean.csproj new file mode 100644 index 0000000..e83722f --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean/Keyfactor.DnsProvider.DigitalOcean.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + disable + true + Keyfactor.Extensions.DomainValidator.DigitalOcean + DigitalOceanDomainValidator + + + + + + + + Always + + + diff --git a/Keyfactor.DnsProvider.DigitalOcean/manifest.json b/Keyfactor.DnsProvider.DigitalOcean/manifest.json new file mode 100644 index 0000000..0dbbaac --- /dev/null +++ b/Keyfactor.DnsProvider.DigitalOcean/manifest.json @@ -0,0 +1,10 @@ +{ + "extensions": { + "Keyfactor.AnyGateway.Extensions.IDomainValidator": { + "DigitalOceanDomainValidator": { + "assemblypath": "DigitalOceanDomainValidator.dll", + "TypeFullName": "Keyfactor.Extensions.DomainValidator.DigitalOcean.DigitalOceanDomainValidator" + } + } + } +} diff --git a/docsource/configuration.md b/docsource/configuration.md new file mode 100644 index 0000000..68300d5 --- /dev/null +++ b/docsource/configuration.md @@ -0,0 +1,48 @@ +### Provider Setup + +Create a DigitalOcean Personal Access Token for the account that owns the domains you want the plugin to manage: + +1. Log in to the DigitalOcean control panel +2. Navigate to **API > Tokens** +3. Generate a new token with `domain:read`, `domain:create`, and `domain:delete` scopes and copy the value + +Provide the token as `DigitalOcean_ApiToken` in the plugin configuration below. + +### Example Configurations + +**Standard configuration:** + +```json +{ + "DigitalOcean_ApiToken": "your-digitalocean-api-token" +} +``` + +### Zone Discovery + +The plugin discovers the appropriate DigitalOcean domain for a record by querying the DigitalOcean API for all domains on the account, then matching the record's domain against domain names from most specific (longest) to least specific. + +### Testing Connectivity + +Test DigitalOcean connectivity using `curl` against the API: + +```bash +# List domains accessible to the account (validates the API token) +curl -s -H "Authorization: Bearer $DIGITALOCEAN_API_TOKEN" https://api.digitalocean.com/v2/domains +``` + +### Troubleshooting + +**Authentication Failures** + +Symptom: `401 Unauthorized` listing domains + +- Verify the API token has not expired or been revoked in the DigitalOcean control panel +- Confirm the token has `domain:read`, `domain:create`, and `domain:delete` scopes (or is a full-access token) + +**Zone Not Found** + +Symptom: `No DigitalOcean domain found for example.com` + +- Verify the domain exists and is active in the DigitalOcean account +- Confirm the account associated with the API token owns that domain diff --git a/docsource/content.md b/docsource/content.md new file mode 100644 index 0000000..c9edc7b --- /dev/null +++ b/docsource/content.md @@ -0,0 +1,13 @@ +## Overview + +The DigitalOcean Provider plugin enables automated DNS-based domain validation for Keyfactor certificate lifecycle management through DigitalOcean. This plugin integrates with the DigitalOcean API to automatically create, verify, and delete DNS TXT records required for domain validation during certificate issuance and renewal. + +## Features + +- Bearer token authentication using a DigitalOcean Personal Access Token +- Automatic zone discovery across all domains on the account, matched by longest domain suffix + +## Requirements + +- A DigitalOcean account with one or more domains managed by DigitalOcean's DNS +- A DigitalOcean Personal Access Token with `domain:read`, `domain:create`, and `domain:delete` scopes (create under **API > Tokens** in the DigitalOcean control panel) diff --git a/integration-manifest.json b/integration-manifest.json new file mode 100644 index 0000000..e465d46 --- /dev/null +++ b/integration-manifest.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "integration_type": "dns-plugin", + "name": "DigitalOcean DNS Plugin", + "status": "production", + "support_level": "kf-supported", + "update_catalog": true, + "link_github": false, + "description": "DNS-01 challenge validation provider using DigitalOcean. Implements the IDomainValidator interface to create, manage, and clean up DNS TXT records in DigitalOcean-hosted domains for ACME domain validation. Authenticates via a Bearer Personal Access Token.", + "release_dir": "Keyfactor.DnsProvider.DigitalOcean/bin/Release", + "release_project": "Keyfactor.DnsProvider.DigitalOcean/Keyfactor.DnsProvider.DigitalOcean.csproj", + "about": { + "dns_provider": { + "providerName": "digitalocean", + "displayName": "DigitalOcean", + "assemblyName": "DigitalOceanDomainValidator", + "fullyQualifiedClassName": "Keyfactor.Extensions.DomainValidator.DigitalOcean.DigitalOceanDomainValidator", + "validationType": "dns-01", + "providerEndpoint": "api.digitalocean.com", + "providerDocsUrl": "https://docs.digitalocean.com/reference/api/reference/domains/", + "serviceStatusUrl": "https://status.digitalocean.com/", + "dns_provider_config": [ + { + "Name": "DigitalOcean_ApiToken", + "DisplayName": "DigitalOcean API Token", + "DataType": 2, + "InstanceLevel": false, + "Hidden": true, + "DefaultValue": "", + "Required": true, + "Description": "DigitalOcean Personal Access Token with domain read/create/delete scopes. Created under API > Tokens in the DigitalOcean control panel." + } + ] + } + } +} From f6b3582ac7207428c7bd90faddebbc11edf1dde9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 15:46:07 +0000 Subject: [PATCH 2/2] docs: auto-generate README and documentation [skip ci] --- README.md | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..febc496 --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +

+ DigitalOcean DNS Provider +

+ +

+ +Integration Status: production +Build +Release +Issues +GitHub Downloads (all assets, all releases) +

+ +

+ + + Support + + · + + Requirements + + · + + Installation + + · + + License + + · + + Related Integrations + +

+ +## Overview + +The DigitalOcean Provider plugin enables automated DNS-based domain validation for Keyfactor certificate lifecycle management through DigitalOcean. This plugin integrates with the DigitalOcean API to automatically create, verify, and delete DNS TXT records required for domain validation during certificate issuance and renewal. + +## Features + +- Automated DNS TXT record creation and deletion in DigitalOcean +- Bearer token authentication using a DigitalOcean Personal Access Token +- Automatic zone discovery across all domains on the account, matched by longest domain suffix + +## Requirements + +### Keyfactor Platform +- Keyfactor AnyCA Gateway REST **26.2 or later** (DNS validation support was added in AnyCA Gateway 26.2) +- A gateway product that supports DNS-01 domain validation (ACME REST Gateway, DigiCert, Sectigo, etc.) + +### DigitalOcean Requirements + +- A DigitalOcean account with one or more domains managed by DigitalOcean's DNS +- A DigitalOcean Personal Access Token with `domain:read`, `domain:create`, and `domain:delete` scopes (create under **API > Tokens** in the DigitalOcean control panel) + +### Runtime Requirements +- .NET 10.0 runtime (provided by the gateway server) +- Network connectivity to api.digitalocean.com (HTTPS/443) + +## Installation + +This plugin is installed alongside any Keyfactor gateway server that supports DNS-01 domain validation (ACME REST Gateway, DigiCert, Sectigo, etc.). The same DLL works with every supported gateway. + +> See the official Keyfactor AnyCA Gateway REST installation documentation for the authoritative install instructions: ****. The steps below are a general guide; defer to the official docs if they diverge. + +### 1. Download the Plugin + +Download the latest release from the [Releases](https://github.com/Keyfactor/digitalocean-dnsplugin/releases) page. + +### 2. Copy the plugin DLLs to the gateway's Extensions folder + +On the server hosting your gateway, unzip the release and copy the contents of the `net10.0` directory into the gateway's `Extensions` folder. + +**Windows** (example path — substitute the gateway product folder for your install): + +```text +C:\Program Files\Keyfactor\\AnyGatewayREST\net10.0\Extensions\ +``` + +**Linux**: + +```text +/opt/keyfactor//AnyGatewayREST/net10.0/Extensions/ +``` + +Replace `` (or `` on Linux) with the gateway you are installing into (e.g. `AcmeGwDns`, `DigiCert`, `Sectigo`). + +### 3. Restart the gateway service + +Restart the AnyGatewayREST Windows service for the gateway you installed the plugin into so the Extensions folder is rescanned. + +## Configuration + +After installing the plugin DLL into the gateway's Extensions folder, configure a new DNS Provider entry in the AnyCA Gateway REST UI and select **DigitalOcean** as the provider type. See the official Keyfactor AnyCA Gateway REST documentation for the canonical UI walkthrough: ****. + +### DigitalOcean Setup + +Create a DigitalOcean Personal Access Token for the account that owns the domains you want the plugin to manage: + +1. Log in to the DigitalOcean control panel +2. Navigate to **API > Tokens** +3. Generate a new token with `domain:read`, `domain:create`, and `domain:delete` scopes and copy the value + +Provide the token as `DigitalOcean_ApiToken` in the plugin configuration below. + +### Configuration Parameters + +| Parameter | Description | Required | Example | +|-----------|-------------|----------|---------| +| `DigitalOcean_ApiToken` | DigitalOcean Personal Access Token with domain read/create/delete scopes. Created under API > Tokens in the DigitalOcean control panel. | Yes | ` ` | + +### Example Configuration + +**Standard configuration:** + +```json +{ + "DigitalOcean_ApiToken": "your-digitalocean-api-token" +} +``` + +## Usage + +### Automatic Domain Validation + +Once configured, the plugin automatically handles DNS validation during certificate enrollment and renewal: + +1. **Record Creation**: Plugin creates a DNS TXT record with the validation challenge +2. **Propagation Wait**: Plugin waits for DNS propagation +3. **Verification**: Plugin verifies the record exists on DigitalOcean nameservers +4. **Cleanup**: Plugin deletes the validation record after successful validation + +### Zone Discovery + +The plugin discovers the appropriate DigitalOcean domain for a record by querying the DigitalOcean API for all domains on the account, then matching the record's domain against domain names from most specific (longest) to least specific. + +### Testing Connectivity + +Test DigitalOcean connectivity using `curl` against the API: + +```bash +# List domains accessible to the account (validates the API token) +curl -s -H "Authorization: Bearer $DIGITALOCEAN_API_TOKEN" https://api.digitalocean.com/v2/domains +``` + +## Troubleshooting + +### Common Issues + +**Authentication Failures** + +Symptom: `401 Unauthorized` listing domains + +- Verify the API token has not expired or been revoked in the DigitalOcean control panel +- Confirm the token has `domain:read`, `domain:create`, and `domain:delete` scopes (or is a full-access token) + +**Zone Not Found** + +Symptom: `No DigitalOcean domain found for example.com` + +- Verify the domain exists and is active in the DigitalOcean account +- Confirm the account associated with the API token owns that domain + +### Logging + +Enable debug logging in the gateway's logging configuration: + +```json +{ + "Logging": { + "LogLevel": { + "Keyfactor.Extensions.DomainValidator.DigitalOcean": "Debug" + } + } +} +``` + +### Service Status + +Check DigitalOcean service status: https://status.digitalocean.com/ + +## Support + +The DigitalOcean DNS Provider plugin is supported by Keyfactor for Keyfactor customers. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com. + +### Resources + +- [DigitalOcean Documentation](https://docs.digitalocean.com/reference/api/reference/domains/) +- [Report Issues](https://github.com/Keyfactor/digitalocean-dnsplugin/issues) +- [Discussions](https://github.com/Keyfactor/digitalocean-dnsplugin/discussions) + +> To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. + +## License + +Apache License 2.0, see [LICENSE](LICENSE). + +## Related Integrations + +See all [Keyfactor DNS Provider plugins](https://github.com/orgs/Keyfactor/repositories?q=dnsplugin).