diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4576d9ed..e7933b5e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **DNS Challenge Support for Wildcard Certificates**: Full support for wildcard SSL certificates using DNS-01 challenges (Issue #21, PR #460, #461)
+ - **Secure DNS Provider Management**: Add, edit, test, and delete DNS provider configurations with AES-256-GCM encrypted credentials
+ - **10+ Supported Providers**: Cloudflare, AWS Route53, DigitalOcean, Google Cloud DNS, Azure DNS, Namecheap, GoDaddy, Hetzner, Vultr, DNSimple
+ - **Automated Certificate Issuance**: Wildcard domains (e.g., `*.example.com`) automatically use DNS-01 challenges via configured providers
+ - **Pre-Save Testing**: Test DNS provider credentials before saving to catch configuration errors early
+ - **Dynamic Configuration**: Provider-specific credential fields with hints and documentation links
+ - **Comprehensive Documentation**: Setup guides for major providers and troubleshooting documentation
+ - **Security First**: Credentials never exposed in API responses, encrypted at rest with CHARON_ENCRYPTION_KEY
+ - See [DNS Providers Guide](docs/guides/dns-providers.md) for setup instructions
- **Universal JSON Template Support for Notifications**: JSON payload templates (minimal, detailed, custom) are now available for all notification services that support JSON payloads, not just generic webhooks (PR #XXX)
- **Discord**: Rich embeds with colors, fields, and custom formatting
- **Slack**: Block Kit messages with sections and interactive elements
diff --git a/backend/.gitignore b/backend/.gitignore
index d3d225c9..7b84507e 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -1 +1,2 @@
backend/seed
+backend/main
diff --git a/backend/dns_handler_coverage.txt b/backend/dns_handler_coverage.txt
new file mode 100644
index 00000000..212f9e31
--- /dev/null
+++ b/backend/dns_handler_coverage.txt
@@ -0,0 +1,54 @@
+mode: set
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:17.85,21.2 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:25.51,27.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:27.16,30.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:33.2,34.30 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:34.30,39.3 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:41.2,44.4 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:49.50,51.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:51.16,54.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:56.2,57.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:57.16,58.45 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:58.45,61.4 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:62.3,63.9 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:66.2,71.33 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:76.53,78.47 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:78.47,81.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:83.2,84.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:84.16,88.14 3 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:89.40,90.50 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:91.39,92.65 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:93.37,95.50 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:98.3,99.9 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:102.2,107.38 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:112.53,114.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:114.16,117.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:119.2,120.47 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:120.47,123.3 2 0
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:125.2,126.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:126.16,130.14 3 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:131.40,133.43 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:134.39,135.65 1 0
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:136.37,138.50 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:141.3,142.9 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:145.2,150.33 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:155.53,157.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:157.16,160.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:162.2,163.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:163.16,164.45 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:164.45,167.4 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:168.3,169.9 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:172.2,172.78 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:177.51,179.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:179.16,182.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:184.2,185.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:185.16,186.45 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:186.45,189.4 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:190.3,191.9 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:194.2,194.31 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:199.62,201.47 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:201.47,204.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:206.2,207.16 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:207.16,210.3 2 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:212.2,212.31 1 1
+/projects/Charon/backend/internal/api/handlers/dns_provider_handler.go:217.55,425.2 2 1
diff --git a/backend/dns_service_coverage.txt b/backend/dns_service_coverage.txt
new file mode 100644
index 00000000..169663bb
--- /dev/null
+++ b/backend/dns_service_coverage.txt
@@ -0,0 +1,91 @@
+mode: set
+/projects/Charon/backend/internal/services/dns_provider_service.go:111.97,116.2 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:119.86,123.2 3 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:126.93,129.16 3 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:129.16,130.45 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:130.45,132.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:133.3,133.18 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:135.2,135.23 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:139.117,141.44 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:141.44,143.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:146.2,146.79 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:146.79,148.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:151.2,152.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:152.16,154.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:156.2,157.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:157.16,159.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:162.2,163.29 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:163.29,165.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:167.2,168.26 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:168.26,170.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:173.2,173.19 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:173.19,175.140 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:175.140,177.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:181.2,192.69 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:192.69,194.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:196.2,196.22 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:200.126,203.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:203.16,205.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:208.2,208.21 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:208.21,210.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:212.2,212.35 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:212.35,214.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:216.2,216.32 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:216.32,218.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:220.2,220.24 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:220.24,222.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:225.2,225.56 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:225.56,227.85 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:227.85,229.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:232.3,233.17 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:233.17,235.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:237.3,238.17 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:238.17,240.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:242.3,242.49 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:246.2,246.44 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:246.44,248.156 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:248.156,250.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:251.3,251.28 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:252.8,252.52 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:252.52,254.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:257.2,257.67 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:257.67,259.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:261.2,261.22 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:265.73,267.25 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:267.25,269.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:270.2,270.30 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:270.30,272.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:273.2,273.12 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:277.86,279.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:279.16,281.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:284.2,285.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:285.16,291.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:294.2,300.20 4 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:300.20,303.3 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:303.8,306.3 2 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:309.2,311.20 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:315.118,317.44 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:317.44,323.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:326.2,326.79 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:326.79,332.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:335.2,335.75 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:339.111,341.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:341.16,343.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:346.2,347.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:347.16,349.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:352.2,353.68 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:353.68,355.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:358.2,362.25 4 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:366.52,367.51 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:367.51,368.32 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:368.32,370.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:372.2,372.14 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:376.84,378.9 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:378.9,380.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:383.2,383.39 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:383.39,384.66 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:384.66,386.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:389.2,389.12 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:395.97,402.71 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:402.71,408.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:411.2,419.3 2 1
diff --git a/backend/dns_service_final.txt b/backend/dns_service_final.txt
new file mode 100644
index 00000000..cd707db9
--- /dev/null
+++ b/backend/dns_service_final.txt
@@ -0,0 +1,91 @@
+mode: set
+/projects/Charon/backend/internal/services/dns_provider_service.go:111.97,116.2 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:119.86,123.2 3 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:126.93,129.16 3 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:129.16,130.45 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:130.45,132.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:133.3,133.18 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:135.2,135.23 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:139.117,141.44 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:141.44,143.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:146.2,146.79 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:146.79,148.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:151.2,152.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:152.16,154.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:156.2,157.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:157.16,159.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:162.2,163.29 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:163.29,165.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:167.2,168.26 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:168.26,170.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:173.2,173.19 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:173.19,175.140 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:175.140,177.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:181.2,192.69 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:192.69,194.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:196.2,196.22 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:200.126,203.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:203.16,205.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:208.2,208.21 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:208.21,210.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:212.2,212.35 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:212.35,214.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:216.2,216.32 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:216.32,218.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:220.2,220.24 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:220.24,222.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:225.2,225.56 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:225.56,227.85 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:227.85,229.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:232.3,233.17 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:233.17,235.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:237.3,238.17 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:238.17,240.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:242.3,242.49 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:246.2,246.44 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:246.44,248.156 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:248.156,250.4 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:251.3,251.28 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:252.8,252.52 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:252.52,254.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:257.2,257.67 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:257.67,259.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:261.2,261.22 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:265.73,267.25 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:267.25,269.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:270.2,270.30 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:270.30,272.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:273.2,273.12 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:277.86,279.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:279.16,281.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:284.2,285.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:285.16,291.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:294.2,300.20 4 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:300.20,303.3 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:303.8,306.3 2 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:309.2,311.20 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:315.118,317.44 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:317.44,323.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:326.2,326.79 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:326.79,332.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:335.2,335.75 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:339.111,341.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:341.16,343.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:346.2,347.16 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:347.16,349.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:352.2,353.68 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:353.68,355.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:358.2,362.25 4 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:366.52,367.51 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:367.51,368.32 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:368.32,370.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:372.2,372.14 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:376.84,378.9 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:378.9,380.3 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:383.2,383.39 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:383.39,384.66 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:384.66,386.4 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:389.2,389.12 1 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:395.97,402.71 2 1
+/projects/Charon/backend/internal/services/dns_provider_service.go:402.71,408.3 1 0
+/projects/Charon/backend/internal/services/dns_provider_service.go:411.2,419.3 2 1
diff --git a/backend/go.mod b/backend/go.mod
index af62efaa..a4b0a07e 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -75,6 +75,7 @@ require (
github.com/prometheus/procfs v0.16.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 0f24844f..9a1900f2 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -164,6 +164,8 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
diff --git a/backend/internal/api/handlers/dns_provider_handler.go b/backend/internal/api/handlers/dns_provider_handler.go
new file mode 100644
index 00000000..39f4daf4
--- /dev/null
+++ b/backend/internal/api/handlers/dns_provider_handler.go
@@ -0,0 +1,425 @@
+package handlers
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/Wikid82/charon/backend/internal/services"
+ "github.com/gin-gonic/gin"
+)
+
+// DNSProviderHandler handles DNS provider API requests.
+type DNSProviderHandler struct {
+ service services.DNSProviderService
+}
+
+// NewDNSProviderHandler creates a new DNS provider handler.
+func NewDNSProviderHandler(service services.DNSProviderService) *DNSProviderHandler {
+ return &DNSProviderHandler{
+ service: service,
+ }
+}
+
+// List handles GET /api/v1/dns-providers
+// Returns all DNS providers without exposing credentials.
+func (h *DNSProviderHandler) List(c *gin.Context) {
+ providers, err := h.service.List(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list DNS providers"})
+ return
+ }
+
+ // Convert to response format with has_credentials indicator
+ responses := make([]services.DNSProviderResponse, len(providers))
+ for i, p := range providers {
+ responses[i] = services.DNSProviderResponse{
+ DNSProvider: p,
+ HasCredentials: p.CredentialsEncrypted != "",
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "providers": responses,
+ "total": len(responses),
+ })
+}
+
+// Get handles GET /api/v1/dns-providers/:id
+// Returns a single DNS provider without exposing credentials.
+func (h *DNSProviderHandler) Get(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
+ return
+ }
+
+ provider, err := h.service.Get(c.Request.Context(), uint(id))
+ if err != nil {
+ if err == services.ErrDNSProviderNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "DNS provider not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve DNS provider"})
+ return
+ }
+
+ response := services.DNSProviderResponse{
+ DNSProvider: *provider,
+ HasCredentials: provider.CredentialsEncrypted != "",
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+// Create handles POST /api/v1/dns-providers
+// Creates a new DNS provider with encrypted credentials.
+func (h *DNSProviderHandler) Create(c *gin.Context) {
+ var req services.CreateDNSProviderRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ provider, err := h.service.Create(c.Request.Context(), req)
+ if err != nil {
+ statusCode := http.StatusBadRequest
+ errorMessage := err.Error()
+
+ switch err {
+ case services.ErrInvalidProviderType:
+ errorMessage = "Unsupported DNS provider type"
+ case services.ErrInvalidCredentials:
+ errorMessage = "Invalid credentials: missing required fields"
+ case services.ErrEncryptionFailed:
+ statusCode = http.StatusInternalServerError
+ errorMessage = "Failed to encrypt credentials"
+ }
+
+ c.JSON(statusCode, gin.H{"error": errorMessage})
+ return
+ }
+
+ response := services.DNSProviderResponse{
+ DNSProvider: *provider,
+ HasCredentials: provider.CredentialsEncrypted != "",
+ }
+
+ c.JSON(http.StatusCreated, response)
+}
+
+// Update handles PUT /api/v1/dns-providers/:id
+// Updates an existing DNS provider.
+func (h *DNSProviderHandler) Update(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
+ return
+ }
+
+ var req services.UpdateDNSProviderRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ provider, err := h.service.Update(c.Request.Context(), uint(id), req)
+ if err != nil {
+ statusCode := http.StatusBadRequest
+ errorMessage := err.Error()
+
+ switch err {
+ case services.ErrDNSProviderNotFound:
+ statusCode = http.StatusNotFound
+ errorMessage = "DNS provider not found"
+ case services.ErrInvalidCredentials:
+ errorMessage = "Invalid credentials: missing required fields"
+ case services.ErrEncryptionFailed:
+ statusCode = http.StatusInternalServerError
+ errorMessage = "Failed to encrypt credentials"
+ }
+
+ c.JSON(statusCode, gin.H{"error": errorMessage})
+ return
+ }
+
+ response := services.DNSProviderResponse{
+ DNSProvider: *provider,
+ HasCredentials: provider.CredentialsEncrypted != "",
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+// Delete handles DELETE /api/v1/dns-providers/:id
+// Deletes a DNS provider.
+func (h *DNSProviderHandler) Delete(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
+ return
+ }
+
+ err = h.service.Delete(c.Request.Context(), uint(id))
+ if err != nil {
+ if err == services.ErrDNSProviderNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "DNS provider not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete DNS provider"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "DNS provider deleted successfully"})
+}
+
+// Test handles POST /api/v1/dns-providers/:id/test
+// Tests a saved DNS provider's credentials.
+func (h *DNSProviderHandler) Test(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 32)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
+ return
+ }
+
+ result, err := h.service.Test(c.Request.Context(), uint(id))
+ if err != nil {
+ if err == services.ErrDNSProviderNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "DNS provider not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test DNS provider"})
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// TestCredentials handles POST /api/v1/dns-providers/test
+// Tests DNS provider credentials without saving them.
+func (h *DNSProviderHandler) TestCredentials(c *gin.Context) {
+ var req services.CreateDNSProviderRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ result, err := h.service.TestCredentials(c.Request.Context(), req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test credentials"})
+ return
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// GetTypes handles GET /api/v1/dns-providers/types
+// Returns the list of supported DNS provider types with their required fields.
+func (h *DNSProviderHandler) GetTypes(c *gin.Context) {
+ types := []gin.H{
+ {
+ "type": "cloudflare",
+ "name": "Cloudflare",
+ "fields": []gin.H{
+ {
+ "name": "api_token",
+ "label": "API Token",
+ "type": "password",
+ "required": true,
+ "hint": "Token with Zone:DNS:Edit permissions",
+ },
+ },
+ "documentation_url": "https://developers.cloudflare.com/api/tokens/",
+ },
+ {
+ "type": "route53",
+ "name": "Amazon Route 53",
+ "fields": []gin.H{
+ {
+ "name": "access_key_id",
+ "label": "Access Key ID",
+ "type": "text",
+ "required": true,
+ },
+ {
+ "name": "secret_access_key",
+ "label": "Secret Access Key",
+ "type": "password",
+ "required": true,
+ },
+ {
+ "name": "region",
+ "label": "AWS Region",
+ "type": "text",
+ "required": true,
+ "default": "us-east-1",
+ },
+ },
+ "documentation_url": "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-routing-traffic.html",
+ },
+ {
+ "type": "digitalocean",
+ "name": "DigitalOcean",
+ "fields": []gin.H{
+ {
+ "name": "auth_token",
+ "label": "API Token",
+ "type": "password",
+ "required": true,
+ "hint": "Personal Access Token with read/write scope",
+ },
+ },
+ "documentation_url": "https://docs.digitalocean.com/reference/api/api-reference/",
+ },
+ {
+ "type": "googleclouddns",
+ "name": "Google Cloud DNS",
+ "fields": []gin.H{
+ {
+ "name": "service_account_json",
+ "label": "Service Account JSON",
+ "type": "textarea",
+ "required": true,
+ "hint": "JSON key file for service account with DNS Administrator role",
+ },
+ {
+ "name": "project",
+ "label": "Project ID",
+ "type": "text",
+ "required": true,
+ },
+ },
+ "documentation_url": "https://cloud.google.com/dns/docs/",
+ },
+ {
+ "type": "namecheap",
+ "name": "Namecheap",
+ "fields": []gin.H{
+ {
+ "name": "api_user",
+ "label": "API Username",
+ "type": "text",
+ "required": true,
+ },
+ {
+ "name": "api_key",
+ "label": "API Key",
+ "type": "password",
+ "required": true,
+ },
+ {
+ "name": "client_ip",
+ "label": "Client IP Address",
+ "type": "text",
+ "required": true,
+ "hint": "Your server's public IP address (whitelisted in Namecheap)",
+ },
+ },
+ "documentation_url": "https://www.namecheap.com/support/api/intro/",
+ },
+ {
+ "type": "godaddy",
+ "name": "GoDaddy",
+ "fields": []gin.H{
+ {
+ "name": "api_key",
+ "label": "API Key",
+ "type": "text",
+ "required": true,
+ },
+ {
+ "name": "api_secret",
+ "label": "API Secret",
+ "type": "password",
+ "required": true,
+ },
+ },
+ "documentation_url": "https://developer.godaddy.com/",
+ },
+ {
+ "type": "azure",
+ "name": "Azure DNS",
+ "fields": []gin.H{
+ {
+ "name": "tenant_id",
+ "label": "Tenant ID",
+ "type": "text",
+ "required": true,
+ },
+ {
+ "name": "client_id",
+ "label": "Client ID",
+ "type": "text",
+ "required": true,
+ },
+ {
+ "name": "client_secret",
+ "label": "Client Secret",
+ "type": "password",
+ "required": true,
+ },
+ {
+ "name": "subscription_id",
+ "label": "Subscription ID",
+ "type": "text",
+ "required": true,
+ },
+ {
+ "name": "resource_group",
+ "label": "Resource Group",
+ "type": "text",
+ "required": true,
+ },
+ },
+ "documentation_url": "https://docs.microsoft.com/en-us/azure/dns/",
+ },
+ {
+ "type": "hetzner",
+ "name": "Hetzner",
+ "fields": []gin.H{
+ {
+ "name": "api_key",
+ "label": "API Key",
+ "type": "password",
+ "required": true,
+ },
+ },
+ "documentation_url": "https://docs.hetzner.com/dns-console/dns/general/dns-overview/",
+ },
+ {
+ "type": "vultr",
+ "name": "Vultr",
+ "fields": []gin.H{
+ {
+ "name": "api_key",
+ "label": "API Key",
+ "type": "password",
+ "required": true,
+ },
+ },
+ "documentation_url": "https://www.vultr.com/api/",
+ },
+ {
+ "type": "dnsimple",
+ "name": "DNSimple",
+ "fields": []gin.H{
+ {
+ "name": "oauth_token",
+ "label": "OAuth Token",
+ "type": "password",
+ "required": true,
+ },
+ {
+ "name": "account_id",
+ "label": "Account ID",
+ "type": "text",
+ "required": true,
+ },
+ },
+ "documentation_url": "https://developer.dnsimple.com/",
+ },
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "types": types,
+ })
+}
diff --git a/backend/internal/api/handlers/dns_provider_handler_test.go b/backend/internal/api/handlers/dns_provider_handler_test.go
new file mode 100644
index 00000000..8ed9d997
--- /dev/null
+++ b/backend/internal/api/handlers/dns_provider_handler_test.go
@@ -0,0 +1,764 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+// MockDNSProviderService is a mock implementation of DNSProviderService for testing.
+type MockDNSProviderService struct {
+ mock.Mock
+}
+
+func (m *MockDNSProviderService) List(ctx context.Context) ([]models.DNSProvider, error) {
+ args := m.Called(ctx)
+ return args.Get(0).([]models.DNSProvider), args.Error(1)
+}
+
+func (m *MockDNSProviderService) Get(ctx context.Context, id uint) (*models.DNSProvider, error) {
+ args := m.Called(ctx, id)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*models.DNSProvider), args.Error(1)
+}
+
+func (m *MockDNSProviderService) Create(ctx context.Context, req services.CreateDNSProviderRequest) (*models.DNSProvider, error) {
+ args := m.Called(ctx, req)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*models.DNSProvider), args.Error(1)
+}
+
+func (m *MockDNSProviderService) Update(ctx context.Context, id uint, req services.UpdateDNSProviderRequest) (*models.DNSProvider, error) {
+ args := m.Called(ctx, id, req)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*models.DNSProvider), args.Error(1)
+}
+
+func (m *MockDNSProviderService) Delete(ctx context.Context, id uint) error {
+ args := m.Called(ctx, id)
+ return args.Error(0)
+}
+
+func (m *MockDNSProviderService) Test(ctx context.Context, id uint) (*services.TestResult, error) {
+ args := m.Called(ctx, id)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*services.TestResult), args.Error(1)
+}
+
+func (m *MockDNSProviderService) TestCredentials(ctx context.Context, req services.CreateDNSProviderRequest) (*services.TestResult, error) {
+ args := m.Called(ctx, req)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(*services.TestResult), args.Error(1)
+}
+
+func (m *MockDNSProviderService) GetDecryptedCredentials(ctx context.Context, id uint) (map[string]string, error) {
+ args := m.Called(ctx, id)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).(map[string]string), args.Error(1)
+}
+
+func setupDNSProviderTestRouter() (*gin.Engine, *MockDNSProviderService) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+
+ api := router.Group("/api/v1")
+ {
+ api.GET("/dns-providers", handler.List)
+ api.GET("/dns-providers/:id", handler.Get)
+ api.POST("/dns-providers", handler.Create)
+ api.PUT("/dns-providers/:id", handler.Update)
+ api.DELETE("/dns-providers/:id", handler.Delete)
+ api.POST("/dns-providers/:id/test", handler.Test)
+ api.POST("/dns-providers/test", handler.TestCredentials)
+ api.GET("/dns-providers/types", handler.GetTypes)
+ }
+
+ return router, mockService
+}
+
+func TestDNSProviderHandler_List(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ providers := []models.DNSProvider{
+ {
+ ID: 1,
+ UUID: "uuid-1",
+ Name: "Cloudflare",
+ ProviderType: "cloudflare",
+ Enabled: true,
+ IsDefault: true,
+ CredentialsEncrypted: "encrypted-data",
+ },
+ {
+ ID: 2,
+ UUID: "uuid-2",
+ Name: "Route53",
+ ProviderType: "route53",
+ Enabled: true,
+ IsDefault: false,
+ CredentialsEncrypted: "encrypted-data-2",
+ },
+ }
+
+ mockService.On("List", mock.Anything).Return(providers, nil)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/dns-providers", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response map[string]interface{}
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, float64(2), response["total"])
+ providersArray := response["providers"].([]interface{})
+ assert.Len(t, providersArray, 2)
+
+ // Verify credentials are not exposed
+ provider1 := providersArray[0].(map[string]interface{})
+ assert.True(t, provider1["has_credentials"].(bool))
+ assert.NotContains(t, provider1, "credentials_encrypted")
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("service error", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.GET("/dns-providers", handler.List)
+
+ mockService.On("List", mock.Anything).Return([]models.DNSProvider{}, errors.New("database error"))
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/dns-providers", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+ })
+}
+
+func TestDNSProviderHandler_Get(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ provider := &models.DNSProvider{
+ ID: 1,
+ UUID: "uuid-1",
+ Name: "Test Provider",
+ ProviderType: "cloudflare",
+ Enabled: true,
+ CredentialsEncrypted: "encrypted-data",
+ }
+
+ mockService.On("Get", mock.Anything, uint(1)).Return(provider, nil)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/dns-providers/1", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response services.DNSProviderResponse
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, uint(1), response.ID)
+ assert.Equal(t, "Test Provider", response.Name)
+ assert.True(t, response.HasCredentials)
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.GET("/dns-providers/:id", handler.Get)
+
+ mockService.On("Get", mock.Anything, uint(999)).Return(nil, services.ErrDNSProviderNotFound)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/dns-providers/999", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("invalid id", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/dns-providers/invalid", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+}
+
+func TestDNSProviderHandler_Create(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ reqBody := services.CreateDNSProviderRequest{
+ Name: "Test Provider",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{
+ "api_token": "test-token",
+ },
+ PropagationTimeout: 120,
+ PollingInterval: 5,
+ IsDefault: true,
+ }
+
+ createdProvider := &models.DNSProvider{
+ ID: 1,
+ UUID: "uuid-1",
+ Name: reqBody.Name,
+ ProviderType: reqBody.ProviderType,
+ Enabled: true,
+ IsDefault: reqBody.IsDefault,
+ PropagationTimeout: reqBody.PropagationTimeout,
+ PollingInterval: reqBody.PollingInterval,
+ CredentialsEncrypted: "encrypted-data",
+ }
+
+ mockService.On("Create", mock.Anything, reqBody).Return(createdProvider, nil)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/api/v1/dns-providers", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+
+ var response services.DNSProviderResponse
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, uint(1), response.ID)
+ assert.Equal(t, "Test Provider", response.Name)
+ assert.True(t, response.HasCredentials)
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("validation error", func(t *testing.T) {
+ reqBody := map[string]interface{}{
+ "name": "Missing Provider Type",
+ // Missing provider_type and credentials
+ }
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/api/v1/dns-providers", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+
+ t.Run("invalid provider type", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.POST("/dns-providers", handler.Create)
+
+ reqBody := services.CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "invalid",
+ Credentials: map[string]string{"key": "value"},
+ }
+
+ mockService.On("Create", mock.Anything, reqBody).Return(nil, services.ErrInvalidProviderType)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/dns-providers", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("invalid credentials", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.POST("/dns-providers", handler.Create)
+
+ reqBody := services.CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{},
+ }
+
+ mockService.On("Create", mock.Anything, reqBody).Return(nil, services.ErrInvalidCredentials)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/dns-providers", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ mockService.AssertExpectations(t)
+ })
+}
+
+func TestDNSProviderHandler_Update(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ newName := "Updated Name"
+ reqBody := services.UpdateDNSProviderRequest{
+ Name: &newName,
+ }
+
+ updatedProvider := &models.DNSProvider{
+ ID: 1,
+ UUID: "uuid-1",
+ Name: newName,
+ ProviderType: "cloudflare",
+ Enabled: true,
+ CredentialsEncrypted: "encrypted-data",
+ }
+
+ mockService.On("Update", mock.Anything, uint(1), reqBody).Return(updatedProvider, nil)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("PUT", "/api/v1/dns-providers/1", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response services.DNSProviderResponse
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, newName, response.Name)
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.PUT("/dns-providers/:id", handler.Update)
+
+ name := "Test"
+ reqBody := services.UpdateDNSProviderRequest{Name: &name}
+
+ mockService.On("Update", mock.Anything, uint(999), reqBody).Return(nil, services.ErrDNSProviderNotFound)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("PUT", "/dns-providers/999", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ mockService.AssertExpectations(t)
+ })
+}
+
+func TestDNSProviderHandler_Delete(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ mockService.On("Delete", mock.Anything, uint(1)).Return(nil)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("DELETE", "/api/v1/dns-providers/1", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response map[string]interface{}
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.Contains(t, response["message"], "deleted successfully")
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.DELETE("/dns-providers/:id", handler.Delete)
+
+ mockService.On("Delete", mock.Anything, uint(999)).Return(services.ErrDNSProviderNotFound)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("DELETE", "/dns-providers/999", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ mockService.AssertExpectations(t)
+ })
+}
+
+func TestDNSProviderHandler_Test(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ testResult := &services.TestResult{
+ Success: true,
+ Message: "Credentials validated successfully",
+ PropagationTimeMs: 1234,
+ }
+
+ mockService.On("Test", mock.Anything, uint(1)).Return(testResult, nil)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/api/v1/dns-providers/1/test", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response services.TestResult
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.True(t, response.Success)
+ assert.Equal(t, "Credentials validated successfully", response.Message)
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("not found", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.POST("/dns-providers/:id/test", handler.Test)
+
+ mockService.On("Test", mock.Anything, uint(999)).Return(nil, services.ErrDNSProviderNotFound)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/dns-providers/999/test", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+ mockService.AssertExpectations(t)
+ })
+}
+
+func TestDNSProviderHandler_TestCredentials(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ t.Run("success", func(t *testing.T) {
+ reqBody := services.CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ }
+
+ testResult := &services.TestResult{
+ Success: true,
+ Message: "Credentials validated",
+ }
+
+ mockService.On("TestCredentials", mock.Anything, reqBody).Return(testResult, nil)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/api/v1/dns-providers/test", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response services.TestResult
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ assert.True(t, response.Success)
+
+ mockService.AssertExpectations(t)
+ })
+
+ t.Run("validation error", func(t *testing.T) {
+ reqBody := map[string]interface{}{
+ "name": "Test",
+ // Missing provider_type and credentials
+ }
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/api/v1/dns-providers/test", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ })
+}
+
+func TestDNSProviderHandler_GetTypes(t *testing.T) {
+ router, _ := setupDNSProviderTestRouter()
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/dns-providers/types", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+
+ var response map[string]interface{}
+ err := json.Unmarshal(w.Body.Bytes(), &response)
+ require.NoError(t, err)
+
+ types := response["types"].([]interface{})
+ assert.NotEmpty(t, types)
+
+ // Verify structure of first type
+ cloudflare := types[0].(map[string]interface{})
+ assert.Equal(t, "cloudflare", cloudflare["type"])
+ assert.Equal(t, "Cloudflare", cloudflare["name"])
+ assert.NotEmpty(t, cloudflare["fields"])
+ assert.NotEmpty(t, cloudflare["documentation_url"])
+
+ // Verify all expected provider types are present
+ providerTypes := make(map[string]bool)
+ for _, t := range types {
+ typeMap := t.(map[string]interface{})
+ providerTypes[typeMap["type"].(string)] = true
+ }
+
+ expectedTypes := []string{
+ "cloudflare", "route53", "digitalocean", "googleclouddns",
+ "namecheap", "godaddy", "azure", "hetzner", "vultr", "dnsimple",
+ }
+
+ for _, expected := range expectedTypes {
+ assert.True(t, providerTypes[expected], "Missing provider type: "+expected)
+ }
+}
+
+func TestDNSProviderHandler_CredentialsNeverExposed(t *testing.T) {
+ router, mockService := setupDNSProviderTestRouter()
+
+ provider := &models.DNSProvider{
+ ID: 1,
+ UUID: "uuid-1",
+ Name: "Test",
+ ProviderType: "cloudflare",
+ CredentialsEncrypted: "super-secret-encrypted-data",
+ }
+
+ t.Run("Get endpoint", func(t *testing.T) {
+ mockService.On("Get", mock.Anything, uint(1)).Return(provider, nil)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/api/v1/dns-providers/1", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.NotContains(t, w.Body.String(), "credentials_encrypted")
+ assert.NotContains(t, w.Body.String(), "super-secret-encrypted-data")
+ assert.Contains(t, w.Body.String(), "has_credentials")
+ })
+
+ t.Run("List endpoint", func(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.GET("/dns-providers", handler.List)
+
+ providers := []models.DNSProvider{*provider}
+ mockService.On("List", mock.Anything).Return(providers, nil)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/dns-providers", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.NotContains(t, w.Body.String(), "credentials_encrypted")
+ assert.NotContains(t, w.Body.String(), "super-secret-encrypted-data")
+ assert.Contains(t, w.Body.String(), "has_credentials")
+ })
+}
+
+func TestDNSProviderHandler_UpdateInvalidID(t *testing.T) {
+ router, _ := setupDNSProviderTestRouter()
+
+ reqBody := map[string]string{"name": "Test"}
+ body, _ := json.Marshal(reqBody)
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("PUT", "/api/v1/dns-providers/invalid", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestDNSProviderHandler_DeleteInvalidID(t *testing.T) {
+ router, _ := setupDNSProviderTestRouter()
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("DELETE", "/api/v1/dns-providers/invalid", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestDNSProviderHandler_TestInvalidID(t *testing.T) {
+ router, _ := setupDNSProviderTestRouter()
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/api/v1/dns-providers/invalid/test", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestDNSProviderHandler_CreateEncryptionFailure(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.POST("/dns-providers", handler.Create)
+
+ reqBody := services.CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ }
+
+ mockService.On("Create", mock.Anything, reqBody).Return(nil, services.ErrEncryptionFailed)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/dns-providers", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+}
+
+func TestDNSProviderHandler_UpdateEncryptionFailure(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.PUT("/dns-providers/:id", handler.Update)
+
+ name := "Test"
+ reqBody := services.UpdateDNSProviderRequest{Name: &name}
+
+ mockService.On("Update", mock.Anything, uint(1), reqBody).Return(nil, services.ErrEncryptionFailed)
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("PUT", "/dns-providers/1", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+}
+
+func TestDNSProviderHandler_GetServiceError(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.GET("/dns-providers/:id", handler.Get)
+
+ mockService.On("Get", mock.Anything, uint(1)).Return(nil, errors.New("database error"))
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("GET", "/dns-providers/1", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+}
+
+func TestDNSProviderHandler_DeleteServiceError(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.DELETE("/dns-providers/:id", handler.Delete)
+
+ mockService.On("Delete", mock.Anything, uint(1)).Return(errors.New("database error"))
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("DELETE", "/dns-providers/1", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+}
+
+func TestDNSProviderHandler_TestServiceError(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.POST("/dns-providers/:id/test", handler.Test)
+
+ mockService.On("Test", mock.Anything, uint(1)).Return(nil, errors.New("service error"))
+
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/dns-providers/1/test", nil)
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+}
+
+func TestDNSProviderHandler_TestCredentialsServiceError(t *testing.T) {
+ mockService := new(MockDNSProviderService)
+ handler := NewDNSProviderHandler(mockService)
+ router := gin.New()
+ router.POST("/dns-providers/test", handler.TestCredentials)
+
+ reqBody := services.CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ }
+
+ mockService.On("TestCredentials", mock.Anything, reqBody).Return(nil, errors.New("service error"))
+
+ body, _ := json.Marshal(reqBody)
+ w := httptest.NewRecorder()
+ req, _ := http.NewRequest("POST", "/dns-providers/test", bytes.NewBuffer(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ mockService.AssertExpectations(t)
+}
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index 48cffc1b..bbf807b3 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -19,6 +19,7 @@ import (
"github.com/Wikid82/charon/backend/internal/caddy"
"github.com/Wikid82/charon/backend/internal/cerberus"
"github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/metrics"
"github.com/Wikid82/charon/backend/internal/models"
@@ -65,6 +66,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
&models.UserPermittedHost{}, // Join table for user permissions
&models.CrowdsecPresetEvent{},
&models.CrowdsecConsoleEnrollment{},
+ &models.DNSProvider{},
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
@@ -240,6 +242,25 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
protected.POST("/domains", domainHandler.Create)
protected.DELETE("/domains/:id", domainHandler.Delete)
+ // DNS Providers - only available if encryption key is configured
+ if cfg.EncryptionKey != "" {
+ encryptionService, err := crypto.NewEncryptionService(cfg.EncryptionKey)
+ if err != nil {
+ logger.Log().WithError(err).Error("Failed to initialize encryption service - DNS provider features will be unavailable")
+ } else {
+ dnsProviderService := services.NewDNSProviderService(db, encryptionService)
+ dnsProviderHandler := handlers.NewDNSProviderHandler(dnsProviderService)
+ protected.GET("/dns-providers", dnsProviderHandler.List)
+ protected.POST("/dns-providers", dnsProviderHandler.Create)
+ protected.GET("/dns-providers/types", dnsProviderHandler.GetTypes)
+ protected.GET("/dns-providers/:id", dnsProviderHandler.Get)
+ protected.PUT("/dns-providers/:id", dnsProviderHandler.Update)
+ protected.DELETE("/dns-providers/:id", dnsProviderHandler.Delete)
+ protected.POST("/dns-providers/:id/test", dnsProviderHandler.Test)
+ protected.POST("/dns-providers/test", dnsProviderHandler.TestCredentials)
+ }
+ }
+
// Docker
dockerService, err := services.NewDockerService()
if err == nil { // Only register if Docker is available
diff --git a/backend/internal/caddy/client_test.go b/backend/internal/caddy/client_test.go
index cc81b238..c1b2aa03 100644
--- a/backend/internal/caddy/client_test.go
+++ b/backend/internal/caddy/client_test.go
@@ -31,7 +31,7 @@ func TestClient_Load_Success(t *testing.T) {
ForwardPort: 8080,
Enabled: true,
},
- }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ }, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
err := client.Load(context.Background(), config)
require.NoError(t, err)
diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go
index 9901ed1a..f607c437 100644
--- a/backend/internal/caddy/config.go
+++ b/backend/internal/caddy/config.go
@@ -15,7 +15,7 @@ import (
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
// This is the core transformation layer from our database model to Caddy config.
-func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
// Define log file paths for Caddy access logs.
// When CrowdSec is enabled, we use /var/log/caddy/access.log which is the standard
// location that CrowdSec's acquis.yaml is configured to monitor.
@@ -73,45 +73,204 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
}
}
- if acmeEmail != "" {
- var issuers []any
+ // Group hosts by DNS provider for TLS automation policies
+ // We need separate policies for:
+ // 1. Wildcard domains with DNS challenge (per DNS provider)
+ // 2. Regular domains with HTTP challenge (default policy)
+ var tlsPolicies []*AutomationPolicy
- // Configure issuers based on provider preference
- switch sslProvider {
- case "letsencrypt":
- acmeIssuer := map[string]any{
- "module": "acme",
- "email": acmeEmail,
+ // Build a map of DNS provider ID to DNS provider config for quick lookup
+ dnsProviderMap := make(map[uint]DNSProviderConfig)
+ for _, cfg := range dnsProviderConfigs {
+ dnsProviderMap[cfg.ID] = cfg
+ }
+
+ // Build a map of DNS provider ID to domains that need DNS challenge
+ dnsProviderDomains := make(map[uint][]string)
+ var httpChallengeDomains []string
+
+ if acmeEmail != "" {
+ for _, host := range hosts {
+ if !host.Enabled || host.DomainNames == "" {
+ continue
}
- if acmeStaging {
- acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+
+ rawDomains := strings.Split(host.DomainNames, ",")
+ var cleanDomains []string
+ for _, d := range rawDomains {
+ d = strings.TrimSpace(d)
+ d = strings.ToLower(d)
+ if d != "" {
+ cleanDomains = append(cleanDomains, d)
+ }
}
- issuers = append(issuers, acmeIssuer)
- case "zerossl":
- issuers = append(issuers, map[string]any{
- "module": "zerossl",
+
+ // Check if this host has wildcard domains and DNS provider
+ if hasWildcard(cleanDomains) && host.DNSProviderID != nil && host.DNSProvider != nil {
+ // Use DNS challenge for this host
+ dnsProviderDomains[*host.DNSProviderID] = append(dnsProviderDomains[*host.DNSProviderID], cleanDomains...)
+ } else {
+ // Use HTTP challenge for this host
+ httpChallengeDomains = append(httpChallengeDomains, cleanDomains...)
+ }
+ }
+
+ // Create DNS challenge policies for each DNS provider
+ for providerID, domains := range dnsProviderDomains {
+ // Find the DNS provider config
+ dnsConfig, ok := dnsProviderMap[providerID]
+ if !ok {
+ logger.Log().WithField("provider_id", providerID).Warn("DNS provider not found in decrypted configs")
+ continue
+ }
+
+ // Build provider config for Caddy with decrypted credentials
+ providerConfig := map[string]any{
+ "name": dnsConfig.ProviderType,
+ }
+
+ // Add all credential fields to the provider config
+ for key, value := range dnsConfig.Credentials {
+ providerConfig[key] = value
+ }
+
+ // Create DNS challenge issuer
+ var issuers []any
+ switch sslProvider {
+ case "letsencrypt":
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ "challenges": map[string]any{
+ "dns": map[string]any{
+ "provider": providerConfig,
+ "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000, // convert seconds to nanoseconds
+ },
+ },
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+ case "zerossl":
+ // ZeroSSL with DNS challenge
+ issuers = append(issuers, map[string]any{
+ "module": "zerossl",
+ "challenges": map[string]any{
+ "dns": map[string]any{
+ "provider": providerConfig,
+ "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
+ },
+ },
+ })
+ default: // "both" or empty
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ "challenges": map[string]any{
+ "dns": map[string]any{
+ "provider": providerConfig,
+ "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
+ },
+ },
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+ issuers = append(issuers, map[string]any{
+ "module": "zerossl",
+ "challenges": map[string]any{
+ "dns": map[string]any{
+ "provider": providerConfig,
+ "propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
+ },
+ },
+ })
+ }
+
+ tlsPolicies = append(tlsPolicies, &AutomationPolicy{
+ Subjects: dedupeDomains(domains),
+ IssuersRaw: issuers,
})
- default: // "both" or empty
- acmeIssuer := map[string]any{
- "module": "acme",
- "email": acmeEmail,
+ }
+
+ // Create default HTTP challenge policy for non-wildcard domains
+ if len(httpChallengeDomains) > 0 {
+ var issuers []any
+ switch sslProvider {
+ case "letsencrypt":
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+ case "zerossl":
+ issuers = append(issuers, map[string]any{
+ "module": "zerossl",
+ })
+ default: // "both" or empty
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+ issuers = append(issuers, map[string]any{
+ "module": "zerossl",
+ })
}
- if acmeStaging {
- acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+
+ tlsPolicies = append(tlsPolicies, &AutomationPolicy{
+ Subjects: dedupeDomains(httpChallengeDomains),
+ IssuersRaw: issuers,
+ })
+ }
+
+ // Create default policy if no specific domains were configured
+ if len(tlsPolicies) == 0 {
+ var issuers []any
+ switch sslProvider {
+ case "letsencrypt":
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+ case "zerossl":
+ issuers = append(issuers, map[string]any{
+ "module": "zerossl",
+ })
+ default: // "both" or empty
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+ issuers = append(issuers, map[string]any{
+ "module": "zerossl",
+ })
}
- issuers = append(issuers, acmeIssuer)
- issuers = append(issuers, map[string]any{
- "module": "zerossl",
+
+ tlsPolicies = append(tlsPolicies, &AutomationPolicy{
+ IssuersRaw: issuers,
})
}
config.Apps.TLS = &TLSApp{
Automation: &AutomationConfig{
- Policies: []*AutomationPolicy{
- {
- IssuersRaw: issuers,
- },
- },
+ Policies: tlsPolicies,
},
}
}
@@ -1319,3 +1478,26 @@ func getDefaultSecurityHeaderProfile() *models.SecurityHeaderProfile {
CrossOriginResourcePolicy: "same-origin",
}
}
+
+// hasWildcard checks if any domain in the list is a wildcard domain
+func hasWildcard(domains []string) bool {
+ for _, domain := range domains {
+ if strings.HasPrefix(domain, "*.") {
+ return true
+ }
+ }
+ return false
+}
+
+// dedupeDomains removes duplicate domains from a list while preserving order
+func dedupeDomains(domains []string) []string {
+ seen := make(map[string]bool)
+ result := make([]string, 0, len(domains))
+ for _, domain := range domains {
+ if !seen[domain] {
+ seen[domain] = true
+ result = append(result, domain)
+ }
+ }
+ return result
+}
diff --git a/backend/internal/caddy/config_crowdsec_test.go b/backend/internal/caddy/config_crowdsec_test.go
index fc07653b..b6f976ef 100644
--- a/backend/internal/caddy/config_crowdsec_test.go
+++ b/backend/internal/caddy/config_crowdsec_test.go
@@ -116,7 +116,7 @@ func TestGenerateConfig_WithCrowdSec(t *testing.T) {
}
// crowdsecEnabled=true should configure app-level CrowdSec
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, true, false, false, false, "", nil, nil, nil, secCfg)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, true, false, false, false, "", nil, nil, nil, secCfg, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.HTTP)
@@ -172,7 +172,7 @@ func TestGenerateConfig_CrowdSecDisabled(t *testing.T) {
}
// crowdsecEnabled=false should NOT configure CrowdSec
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.HTTP)
diff --git a/backend/internal/caddy/config_extra_test.go b/backend/internal/caddy/config_extra_test.go
index e78e9a66..b773a1fa 100644
--- a/backend/internal/caddy/config_extra_test.go
+++ b/backend/internal/caddy/config_extra_test.go
@@ -11,7 +11,7 @@ import (
)
func TestGenerateConfig_CatchAllFrontend(t *testing.T) {
- cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -33,7 +33,7 @@ func TestGenerateConfig_AdvancedInvalidJSON(t *testing.T) {
},
}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -64,7 +64,7 @@ func TestGenerateConfig_AdvancedArrayHandler(t *testing.T) {
},
}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -78,7 +78,7 @@ func TestGenerateConfig_LowercaseDomains(t *testing.T) {
hosts := []models.ProxyHost{
{UUID: "d1", DomainNames: "UPPER.EXAMPLE.COM", ForwardHost: "a", ForwardPort: 80, Enabled: true},
}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
@@ -94,7 +94,7 @@ func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) {
Enabled: true,
AdvancedConfig: `{"handler":"headers","response":{"set":{"X-Obj":["1"]}}}`,
}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// First handler should be headers
@@ -111,7 +111,7 @@ func TestGenerateConfig_AdvancedHeadersStringToArray(t *testing.T) {
Enabled: true,
AdvancedConfig: `{"handler":"headers","request":{"set":{"Upgrade":"websocket"}},"response":{"set":{"X-Obj":"1"}}}`,
}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
@@ -172,7 +172,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
aclH, err := buildACLHandler(&acl, "")
require.NoError(t, err)
require.NotNil(t, aclH)
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Accept either a subroute (ACL) or reverse_proxy as first handler
@@ -184,7 +184,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "u1", DomainNames: ", test.example.com", ForwardHost: "a", ForwardPort: 80, Enabled: true}}
- cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
require.Equal(t, []string{"test.example.com"}, route.Match[0].Host)
@@ -192,7 +192,7 @@ func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
host := models.ProxyHost{UUID: "adv3", DomainNames: "nohandler.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `{"foo":"bar"}`}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// No headers handler appended; last handler is reverse_proxy
@@ -202,7 +202,7 @@ func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) {
host := models.ProxyHost{UUID: "adv4", DomainNames: "struct.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `42`}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Expect main reverse proxy handler exists but no appended advanced handler
@@ -229,7 +229,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"}
// Set rate limit values so rate_limit handler is included (uses caddy-ratelimit format)
secCfg := &models.SecurityConfig{CrowdSecMode: "local", RateLimitRequests: 100, RateLimitWindowSec: 60}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, secCfg)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, secCfg, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
@@ -252,7 +252,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
func TestGenerateConfig_SecurityPipeline_OmitWhenDisabled(t *testing.T) {
host := models.ProxyHost{UUID: "pipe2", DomainNames: "pipe2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
@@ -315,7 +315,7 @@ func TestGetAccessLogPath(t *testing.T) {
// TestGenerateConfig_LoggingConfigured verifies logging is configured in GenerateConfig output
func TestGenerateConfig_LoggingConfigured(t *testing.T) {
- cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, true, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, true, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// Logging should be configured
diff --git a/backend/internal/caddy/config_generate_additional_test.go b/backend/internal/caddy/config_generate_additional_test.go
index b7d173df..259bd4be 100644
--- a/backend/internal/caddy/config_generate_additional_test.go
+++ b/backend/internal/caddy/config_generate_additional_test.go
@@ -22,7 +22,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
}
// Zerossl provider
- cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfgZ, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "zerossl", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfgZ.Apps.TLS)
// Expect only zerossl issuer present
@@ -37,7 +37,7 @@ func TestGenerateConfig_ZerosslAndBothProviders(t *testing.T) {
require.True(t, foundZerossl)
// Default/both provider
- cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfgBoth, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
issuersBoth := cfgBoth.Apps.TLS.Automation.Policies[0].IssuersRaw
// We should have at least 2 issuers (acme + zerossl)
@@ -55,7 +55,7 @@ func TestGenerateConfig_SecurityPipeline_Order_Locations(t *testing.T) {
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"}
// Set rate limit values so rate_limit handler is included (uses caddy-ratelimit format)
sec := &models.SecurityConfig{CrowdSecMode: "local", RateLimitRequests: 100, RateLimitWindowSec: 60}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, sec, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
@@ -100,7 +100,7 @@ func TestGenerateConfig_ACLLogWarning(t *testing.T) {
acl := models.AccessList{ID: 300, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid-json"}
host := models.ProxyHost{UUID: "acl-log", DomainNames: "acl-err.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
@@ -112,7 +112,7 @@ func TestGenerateConfig_ACLHandlerIncluded(t *testing.T) {
ipRules := `[ { "cidr": "10.0.0.0/8" } ]`
acl := models.AccessList{ID: 301, Name: "WL3", Enabled: true, Type: "whitelist", IPRules: ipRules}
host := models.ProxyHost{UUID: "acl-incl", DomainNames: "acl-incl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
@@ -140,7 +140,7 @@ func TestGenerateConfig_DecisionsBlockWithAdminExclusion(t *testing.T) {
host := models.ProxyHost{UUID: "dec1", DomainNames: "dec.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
// create a security decision to block 1.2.3.4
dec := models.SecurityDecision{Action: "block", IP: "1.2.3.4"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
b, _ := json.MarshalIndent(route.Handle, "", " ")
@@ -170,7 +170,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
host := models.ProxyHost{UUID: "wafref", DomainNames: "wafref.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
// No rulesets provided but secCfg references a rulesource
sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "nonexistent-rs"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec, nil)
require.NoError(t, err)
// Since a ruleset name was requested but none exists, NO waf handler should be created
// (Bug fix: don't create a no-op WAF handler without directives)
@@ -185,7 +185,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
rulesets := []models.SecurityRuleSet{{Name: "owasp-crs"}}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp.conf"}
sec2 := &models.SecurityConfig{WAFMode: "block", WAFLearning: true}
- cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", rulesets, rulesetPaths, nil, sec2)
+ cfg2, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", rulesets, rulesetPaths, nil, sec2, nil)
require.NoError(t, err)
route2 := cfg2.Apps.HTTP.Servers["charon_server"].Routes[0]
monitorFound := false
@@ -200,7 +200,7 @@ func TestGenerateConfig_WAFModeAndRulesetReference(t *testing.T) {
func TestGenerateConfig_WAFModeDisabledSkipsHandler(t *testing.T) {
host := models.ProxyHost{UUID: "waf-disabled", DomainNames: "wafd.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
sec := &models.SecurityConfig{WAFMode: "disabled", WAFRulesSource: "owasp-crs"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, nil, nil, sec, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
for _, h := range route.Handle {
@@ -215,7 +215,7 @@ func TestGenerateConfig_WAFSelectedSetsContentAndMode(t *testing.T) {
rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"}
sec := &models.SecurityConfig{WAFMode: "block"}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-crs.conf"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, sec, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
found := false
@@ -234,7 +234,7 @@ func TestGenerateConfig_DecisionAdminPartsEmpty(t *testing.T) {
host := models.ProxyHost{UUID: "dec2", DomainNames: "dec2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
dec := models.SecurityDecision{Action: "block", IP: "2.3.4.5"}
// Provide an adminWhitelist with an empty segment to trigger p == ""
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, ", 10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, ", 10.0.0.1/32", nil, nil, []models.SecurityDecision{dec}, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
found := false
@@ -271,7 +271,7 @@ func TestGenerateConfig_WAFUsesRuleSet(t *testing.T) {
host := models.ProxyHost{UUID: "waf-1", DomainNames: "waf.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
rs := models.SecurityRuleSet{Name: "owasp-crs", SourceURL: "http://example.com/owasp", Content: "rule 1"}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-crs.conf"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// check waf handler present with directives containing Include
@@ -295,7 +295,7 @@ func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig(t *testing.T) {
host := models.ProxyHost{UUID: "waf-host-adv", DomainNames: "waf-adv.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AdvancedConfig: "{\"handler\":\"waf\",\"ruleset_name\":\"host-rs\"}"}
rs := models.SecurityRuleSet{Name: "host-rs", SourceURL: "http://example.com/host-rs", Content: "rule X"}
rulesetPaths := map[string]string{"host-rs": "/tmp/host-rs.conf"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// check waf handler present with directives containing Include from host AdvancedConfig
@@ -316,7 +316,7 @@ func TestGenerateConfig_WAFUsesRuleSetFromAdvancedConfig_Array(t *testing.T) {
host := models.ProxyHost{UUID: "waf-host-adv-arr", DomainNames: "waf-adv-arr.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080, AdvancedConfig: "[{\"handler\":\"waf\",\"ruleset_name\":\"host-rs-array\"}]"}
rs := models.SecurityRuleSet{Name: "host-rs-array", SourceURL: "http://example.com/host-rs-array", Content: "rule X"}
rulesetPaths := map[string]string{"host-rs-array": "/tmp/host-rs-array.conf"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", []models.SecurityRuleSet{rs}, rulesetPaths, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// check waf handler present with directives containing Include from host AdvancedConfig array
@@ -340,7 +340,7 @@ func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) {
host := models.ProxyHost{UUID: "waf-fallback", DomainNames: "waf-fallback.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
sec := &models.SecurityConfig{WAFMode: "block", WAFRulesSource: "owasp-crs"}
rulesetPaths := map[string]string{"owasp-crs": "/tmp/owasp-fallback.conf"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, rulesetPaths, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, true, false, false, "", nil, rulesetPaths, nil, sec, nil)
require.NoError(t, err)
// since secCfg requested owasp-crs and we have a path, the waf handler should include the path in directives
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
@@ -359,7 +359,7 @@ func TestGenerateConfig_WAFUsesRulesetFromSecCfgFallback(t *testing.T) {
func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) {
host := models.ProxyHost{UUID: "rl-1", DomainNames: "rl.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
sec := &models.SecurityConfig{RateLimitRequests: 10, RateLimitWindowSec: 60, RateLimitBurst: 5}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, true, false, "", nil, nil, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, true, false, "", nil, nil, nil, sec, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
found := false
@@ -384,7 +384,7 @@ func TestGenerateConfig_RateLimitFromSecCfg(t *testing.T) {
func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) {
host := models.ProxyHost{UUID: "cs-1", DomainNames: "cs.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
sec := &models.SecurityConfig{CrowdSecMode: "local", CrowdSecAPIURL: "http://cs.local"}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, false, false, false, "", nil, nil, nil, sec)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, false, false, false, "", nil, nil, nil, sec, nil)
require.NoError(t, err)
// Check app-level CrowdSec configuration
@@ -414,7 +414,7 @@ func TestGenerateConfig_CrowdSecHandlerFromSecCfg(t *testing.T) {
}
func TestGenerateConfig_EmptyHostsAndNoFrontend(t *testing.T) {
- cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{}, "/data/caddy/data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// Should return base config without server routes
_, found := cfg.Apps.HTTP.Servers["charon_server"]
@@ -426,7 +426,7 @@ func TestGenerateConfig_SkipsInvalidCustomCert(t *testing.T) {
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "CustomCert", Provider: "custom", Certificate: "cert", PrivateKey: ""}
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: ptrUint(1)}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// Custom cert missing key should not be in LoadPEM
if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
@@ -439,7 +439,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
// Two hosts with same domain - one newer than other should be kept only once
h1 := models.ProxyHost{UUID: "h1", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}
h2 := models.ProxyHost{UUID: "h2", DomainNames: "dup.com", Enabled: true, ForwardHost: "127.0.0.2", ForwardPort: 8081}
- cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{h1, h2}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Expect that only one route exists for dup.com (one for the domain)
@@ -449,7 +449,7 @@ func TestGenerateConfig_SkipsDuplicateDomains(t *testing.T) {
func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
cert := models.SSLCertificate{ID: 1, UUID: "c1", Name: "LoadPEM", Provider: "custom", Certificate: "cert", PrivateKey: "key"}
host := models.ProxyHost{UUID: "h1", DomainNames: "pem.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, Certificate: &cert, CertificateID: &cert.ID}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg.Apps.TLS)
require.NotNil(t, cfg.Apps.TLS.Certificates)
@@ -457,7 +457,7 @@ func TestGenerateConfig_LoadPEMSetsTLSWhenNoACME(t *testing.T) {
func TestGenerateConfig_DefaultAcmeStaging(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080}}
- cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "", true, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
// Should include acme issuer with CA staging URL
issuers := cfg.Apps.TLS.Automation.Policies[0].IssuersRaw
@@ -478,7 +478,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
// create host with an ACL with invalid JSON to force buildACLHandler to error
acl := models.AccessList{ID: 10, Name: "BadACL", Enabled: true, Type: "blacklist", IPRules: "invalid"}
host := models.ProxyHost{UUID: "h1", DomainNames: "a.example.com", Enabled: true, ForwardHost: "127.0.0.1", ForwardPort: 8080, AccessListID: &acl.ID, AccessList: &acl}
- cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{host}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Even if ACL handler error occurs, config should still be returned with routes
@@ -489,7 +489,7 @@ func TestGenerateConfig_ACLHandlerBuildError(t *testing.T) {
func TestGenerateConfig_SkipHostDomainEmptyAndDisabled(t *testing.T) {
disabled := models.ProxyHost{UUID: "h1", Enabled: false, DomainNames: "skip.com", ForwardHost: "127.0.0.1", ForwardPort: 8080}
emptyDomain := models.ProxyHost{UUID: "h2", Enabled: true, DomainNames: "", ForwardHost: "127.0.0.1", ForwardPort: 8080}
- cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig([]models.ProxyHost{disabled, emptyDomain}, "/data/caddy/data", "", "/frontend/dist", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
// Both hosts should be skipped; only routes from no hosts should be only catch-all if frontend provided
diff --git a/backend/internal/caddy/config_generate_test.go b/backend/internal/caddy/config_generate_test.go
index 91f6981e..1ce35a6f 100644
--- a/backend/internal/caddy/config_generate_test.go
+++ b/backend/internal/caddy/config_generate_test.go
@@ -24,7 +24,7 @@ func TestGenerateConfig_CustomCertsAndTLS(t *testing.T) {
Locations: []models.Location{{Path: "/app", ForwardHost: "127.0.0.1", ForwardPort: 8081}},
},
}
- cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil)
+ cfg, err := GenerateConfig(hosts, "/data/caddy/data", "admin@example.com", "/frontend/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, cfg)
// TLS should be configured
diff --git a/backend/internal/caddy/config_test.go b/backend/internal/caddy/config_test.go
index db1e2732..6a9d9b22 100644
--- a/backend/internal/caddy/config_test.go
+++ b/backend/internal/caddy/config_test.go
@@ -11,7 +11,7 @@ import (
)
func TestGenerateConfig_Empty(t *testing.T) {
- config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.HTTP)
require.Empty(t, config.Apps.HTTP.Servers)
@@ -35,7 +35,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.HTTP)
require.Len(t, config.Apps.HTTP.Servers, 1)
@@ -77,7 +77,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
@@ -94,10 +94,8 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
Enabled: true,
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
- require.NotNil(t, config.Apps.HTTP)
-
route := config.Apps.HTTP.Servers["charon_server"].Routes[0]
handler := route.Handle[0]
@@ -116,7 +114,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
@@ -125,7 +123,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
func TestGenerateConfig_Logging(t *testing.T) {
hosts := []models.ProxyHost{}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Logging)
@@ -151,7 +149,7 @@ func TestGenerateConfig_IPHostsSkipAutoHTTPS(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
server := config.Apps.HTTP.Servers["charon_server"]
@@ -201,7 +199,7 @@ func TestGenerateConfig_Advanced(t *testing.T) {
},
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config)
@@ -249,7 +247,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) {
}
// Test with staging enabled
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "", nil, nil, nil, nil)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS)
@@ -265,7 +263,7 @@ func TestGenerateConfig_ACMEStaging(t *testing.T) {
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"])
// Test with staging disabled (production)
- config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.TLS)
require.NotNil(t, config.Apps.TLS.Automation)
@@ -459,7 +457,7 @@ func TestGenerateConfig_WithRateLimiting(t *testing.T) {
}
// rateLimitEnabled=true should include the handler
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, true, false, "", nil, nil, nil, secCfg)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, true, false, "", nil, nil, nil, secCfg, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.HTTP)
@@ -978,7 +976,7 @@ func TestGenerateConfig_WithWAFPerHostDisabled(t *testing.T) {
WAFRulesSource: "owasp-crs",
}
- config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, true, false, false, "", rulesets, rulesetPaths, nil, secCfg)
+ config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, true, false, false, "", rulesets, rulesetPaths, nil, secCfg, nil)
require.NoError(t, err)
require.NotNil(t, config.Apps.HTTP)
diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go
index 6e191efd..be04e96f 100644
--- a/backend/internal/caddy/manager.go
+++ b/backend/internal/caddy/manager.go
@@ -14,6 +14,7 @@ import (
"gorm.io/gorm"
"github.com/Wikid82/charon/backend/internal/config"
+ "github.com/Wikid82/charon/backend/internal/crypto"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
)
@@ -32,6 +33,15 @@ var (
validateConfigFunc = Validate
)
+// DNSProviderConfig contains a DNS provider with its decrypted credentials
+// for use in Caddy DNS challenge configuration generation
+type DNSProviderConfig struct {
+ ID uint
+ ProviderType string
+ PropagationTimeout int
+ Credentials map[string]string
+}
+
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
type Manager struct {
client *Client
@@ -58,10 +68,69 @@ func NewManager(client *Client, db *gorm.DB, configDir, frontendDir string, acme
func (m *Manager) ApplyConfig(ctx context.Context) error {
// Fetch all proxy hosts from database
var hosts []models.ProxyHost
- if err := m.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Find(&hosts).Error; err != nil {
+ if err := m.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Preload("SecurityHeaderProfile").Preload("DNSProvider").Find(&hosts).Error; err != nil {
return fmt.Errorf("fetch proxy hosts: %w", err)
}
+ // Fetch all DNS providers for DNS challenge configuration
+ var dnsProviders []models.DNSProvider
+ if err := m.db.Where("enabled = ?", true).Find(&dnsProviders).Error; err != nil {
+ logger.Log().WithError(err).Warn("failed to load DNS providers for config generation")
+ }
+
+ // Decrypt DNS provider credentials for config generation
+ // We need an encryption service to decrypt the credentials
+ var dnsProviderConfigs []DNSProviderConfig
+ if len(dnsProviders) > 0 {
+ // Try to get encryption key from environment
+ encryptionKey := os.Getenv("CHARON_ENCRYPTION_KEY")
+ if encryptionKey == "" {
+ // Try alternative env vars
+ for _, key := range []string{"ENCRYPTION_KEY", "CERBERUS_ENCRYPTION_KEY"} {
+ if val := os.Getenv(key); val != "" {
+ encryptionKey = val
+ break
+ }
+ }
+ }
+
+ if encryptionKey != "" {
+ // Import crypto package for inline decryption
+ encryptor, err := crypto.NewEncryptionService(encryptionKey)
+ if err != nil {
+ logger.Log().WithError(err).Warn("failed to initialize encryption service for DNS provider credentials")
+ } else {
+ // Decrypt each DNS provider's credentials
+ for _, provider := range dnsProviders {
+ if provider.CredentialsEncrypted == "" {
+ continue
+ }
+
+ decryptedData, err := encryptor.Decrypt(provider.CredentialsEncrypted)
+ if err != nil {
+ logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to decrypt DNS provider credentials")
+ continue
+ }
+
+ var credentials map[string]string
+ if err := json.Unmarshal(decryptedData, &credentials); err != nil {
+ logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to parse DNS provider credentials")
+ continue
+ }
+
+ dnsProviderConfigs = append(dnsProviderConfigs, DNSProviderConfig{
+ ID: provider.ID,
+ ProviderType: provider.ProviderType,
+ PropagationTimeout: provider.PropagationTimeout,
+ Credentials: credentials,
+ })
+ }
+ }
+ } else {
+ logger.Log().Warn("CHARON_ENCRYPTION_KEY not set, DNS challenge configuration will be skipped")
+ }
+ }
+
// Fetch ACME email setting
var acmeEmailSetting models.Setting
var acmeEmail string
@@ -225,7 +294,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
}
}
- generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg)
+ generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs)
if err != nil {
return fmt.Errorf("generate config: %w", err)
}
diff --git a/backend/internal/caddy/manager_additional_test.go b/backend/internal/caddy/manager_additional_test.go
index f67f05c2..83a72246 100644
--- a/backend/internal/caddy/manager_additional_test.go
+++ b/backend/internal/caddy/manager_additional_test.go
@@ -420,7 +420,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
// stub generateConfigFunc to always return error
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
return nil, fmt.Errorf("generate fail")
}
defer func() { generateConfigFunc = orig }()
@@ -598,7 +598,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T)
// Stub generateConfigFunc to capture adminWhitelist
var capturedAdmin string
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
capturedAdmin = adminWhitelist
// return minimal config
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
@@ -649,7 +649,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) {
var capturedRules []models.SecurityRuleSet
orig := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
capturedRules = rulesets
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
}
@@ -704,10 +704,10 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) {
var capturedWafEnabled bool
var capturedRulesets []models.SecurityRuleSet
origGen := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
capturedWafEnabled = wafEnabled
capturedRulesets = rulesets
- return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg)
+ return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
}
defer func() { generateConfigFunc = origGen }()
@@ -809,9 +809,9 @@ func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) {
// Capture rulesetPaths from GenerateConfig
var capturedPaths map[string]string
origGen := generateConfigFunc
- generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+ generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
capturedPaths = rulesetPaths
- return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg)
+ return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
}
defer func() { generateConfigFunc = origGen }()
diff --git a/backend/internal/caddy/manager_ssl_provider_test.go b/backend/internal/caddy/manager_ssl_provider_test.go
index 7ba7cdcf..dd4bc320 100644
--- a/backend/internal/caddy/manager_ssl_provider_test.go
+++ b/backend/internal/caddy/manager_ssl_provider_test.go
@@ -17,8 +17,8 @@ import (
)
// mockGenerateConfigFunc creates a mock config generator that captures parameters
-func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig) (*Config, error) {
- return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error) {
+func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig) (*Config, error) {
+ return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
*capturedProvider = sslProvider
*capturedStaging = acmeStaging
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go
index affd0c9e..5fce7ba8 100644
--- a/backend/internal/caddy/types.go
+++ b/backend/internal/caddy/types.go
@@ -259,6 +259,18 @@ type AutomationConfig struct {
// AutomationPolicy defines certificate management for specific domains.
type AutomationPolicy struct {
- Subjects []string `json:"subjects,omitempty"`
- IssuersRaw []any `json:"issuers,omitempty"`
+ Subjects []string `json:"subjects,omitempty"`
+ IssuersRaw []any `json:"issuers,omitempty"`
+}
+
+// DNSChallengeConfig configures DNS-01 challenge settings
+type DNSChallengeConfig struct {
+ Provider map[string]any `json:"provider"`
+ PropagationTimeout int64 `json:"propagation_timeout,omitempty"` // nanoseconds
+ Resolvers []string `json:"resolvers,omitempty"`
+}
+
+// ChallengesConfig configures ACME challenge types
+type ChallengesConfig struct {
+ DNS *DNSChallengeConfig `json:"dns,omitempty"`
}
diff --git a/backend/internal/caddy/validator_test.go b/backend/internal/caddy/validator_test.go
index 6e9aaab8..047b5b09 100644
--- a/backend/internal/caddy/validator_test.go
+++ b/backend/internal/caddy/validator_test.go
@@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) {
},
}
- config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil)
+ config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
err := Validate(config)
require.NoError(t, err)
}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index ababe9e6..bc153360 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -19,6 +19,7 @@ type Config struct {
ImportCaddyfile string
ImportDir string
JWTSecret string
+ EncryptionKey string
ACMEStaging bool
Debug bool
Security SecurityConfig
@@ -49,6 +50,7 @@ func Load() (Config, error) {
ImportCaddyfile: getEnvAny("/import/Caddyfile", "CHARON_IMPORT_CADDYFILE", "CPM_IMPORT_CADDYFILE"),
ImportDir: getEnvAny(filepath.Join("data", "imports"), "CHARON_IMPORT_DIR", "CPM_IMPORT_DIR"),
JWTSecret: getEnvAny("change-me-in-production", "CHARON_JWT_SECRET", "CPM_JWT_SECRET"),
+ EncryptionKey: getEnvAny("", "CHARON_ENCRYPTION_KEY"),
ACMEStaging: getEnvAny("", "CHARON_ACME_STAGING", "CPM_ACME_STAGING") == "true",
Security: SecurityConfig{
CrowdSecMode: getEnvAny("disabled", "CERBERUS_SECURITY_CROWDSEC_MODE", "CHARON_SECURITY_CROWDSEC_MODE", "CPM_SECURITY_CROWDSEC_MODE"),
diff --git a/backend/internal/crypto/encryption.go b/backend/internal/crypto/encryption.go
new file mode 100644
index 00000000..2d2efd4c
--- /dev/null
+++ b/backend/internal/crypto/encryption.go
@@ -0,0 +1,95 @@
+// Package crypto provides cryptographic services for sensitive data.
+package crypto
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+)
+
+// EncryptionService provides AES-256-GCM encryption and decryption.
+// The service is thread-safe and can be shared across goroutines.
+type EncryptionService struct {
+ key []byte // 32 bytes for AES-256
+}
+
+// NewEncryptionService creates a new encryption service with the provided base64-encoded key.
+// The key must be exactly 32 bytes (256 bits) when decoded.
+func NewEncryptionService(keyBase64 string) (*EncryptionService, error) {
+ key, err := base64.StdEncoding.DecodeString(keyBase64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base64 key: %w", err)
+ }
+
+ if len(key) != 32 {
+ return nil, fmt.Errorf("invalid key length: expected 32 bytes, got %d bytes", len(key))
+ }
+
+ return &EncryptionService{
+ key: key,
+ }, nil
+}
+
+// Encrypt encrypts plaintext using AES-256-GCM and returns base64-encoded ciphertext.
+// The nonce is randomly generated and prepended to the ciphertext.
+func (s *EncryptionService) Encrypt(plaintext []byte) (string, error) {
+ block, err := aes.NewCipher(s.key)
+ if err != nil {
+ return "", fmt.Errorf("failed to create cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", fmt.Errorf("failed to create GCM: %w", err)
+ }
+
+ // Generate random nonce
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", fmt.Errorf("failed to generate nonce: %w", err)
+ }
+
+ // Encrypt and prepend nonce to ciphertext
+ ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
+
+ // Return base64-encoded result
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+// Decrypt decrypts base64-encoded ciphertext using AES-256-GCM.
+// The nonce is expected to be prepended to the ciphertext.
+func (s *EncryptionService) Decrypt(ciphertextB64 string) ([]byte, error) {
+ ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base64 ciphertext: %w", err)
+ }
+
+ block, err := aes.NewCipher(s.key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create GCM: %w", err)
+ }
+
+ nonceSize := gcm.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return nil, fmt.Errorf("ciphertext too short: expected at least %d bytes, got %d bytes", nonceSize, len(ciphertext))
+ }
+
+ // Extract nonce and ciphertext
+ nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
+
+ // Decrypt
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return nil, fmt.Errorf("decryption failed: %w", err)
+ }
+
+ return plaintext, nil
+}
diff --git a/backend/internal/crypto/encryption_test.go b/backend/internal/crypto/encryption_test.go
new file mode 100644
index 00000000..de35a264
--- /dev/null
+++ b/backend/internal/crypto/encryption_test.go
@@ -0,0 +1,284 @@
+package crypto
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestNewEncryptionService_ValidKey tests successful creation with valid 32-byte key.
+func TestNewEncryptionService_ValidKey(t *testing.T) {
+ // Generate a valid 32-byte key
+ key := make([]byte, 32)
+ _, err := rand.Read(key)
+ require.NoError(t, err)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ assert.NoError(t, err)
+ assert.NotNil(t, svc)
+ assert.Equal(t, 32, len(svc.key))
+}
+
+// TestNewEncryptionService_InvalidBase64 tests error handling for invalid base64.
+func TestNewEncryptionService_InvalidBase64(t *testing.T) {
+ invalidBase64 := "not-valid-base64!@#$"
+
+ svc, err := NewEncryptionService(invalidBase64)
+ assert.Error(t, err)
+ assert.Nil(t, svc)
+ assert.Contains(t, err.Error(), "invalid base64 key")
+}
+
+// TestNewEncryptionService_WrongKeyLength tests error handling for incorrect key length.
+func TestNewEncryptionService_WrongKeyLength(t *testing.T) {
+ tests := []struct {
+ name string
+ keyLength int
+ }{
+ {"16 bytes", 16},
+ {"24 bytes", 24},
+ {"31 bytes", 31},
+ {"33 bytes", 33},
+ {"0 bytes", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ key := make([]byte, tt.keyLength)
+ _, _ = rand.Read(key)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ assert.Error(t, err)
+ assert.Nil(t, svc)
+ assert.Contains(t, err.Error(), "invalid key length")
+ })
+ }
+}
+
+// TestEncryptDecrypt_RoundTrip tests that encrypt followed by decrypt returns original plaintext.
+func TestEncryptDecrypt_RoundTrip(t *testing.T) {
+ key := make([]byte, 32)
+ _, err := rand.Read(key)
+ require.NoError(t, err)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ plaintext string
+ }{
+ {"simple text", "Hello, World!"},
+ {"with special chars", "P@ssw0rd!#$%^&*()"},
+ {"json data", `{"api_token":"sk_test_12345","region":"us-east-1"}`},
+ {"unicode", "こんにちは世界 🌍"},
+ {"long text", strings.Repeat("Lorem ipsum dolor sit amet. ", 100)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Encrypt
+ ciphertext, err := svc.Encrypt([]byte(tt.plaintext))
+ require.NoError(t, err)
+ assert.NotEmpty(t, ciphertext)
+
+ // Verify ciphertext is base64
+ _, err = base64.StdEncoding.DecodeString(ciphertext)
+ assert.NoError(t, err)
+
+ // Decrypt
+ decrypted, err := svc.Decrypt(ciphertext)
+ require.NoError(t, err)
+ assert.Equal(t, tt.plaintext, string(decrypted))
+ })
+ }
+}
+
+// TestEncrypt_EmptyPlaintext tests encryption of empty plaintext.
+func TestEncrypt_EmptyPlaintext(t *testing.T) {
+ key := make([]byte, 32)
+ _, err := rand.Read(key)
+ require.NoError(t, err)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ require.NoError(t, err)
+
+ // Encrypt empty plaintext
+ ciphertext, err := svc.Encrypt([]byte{})
+ assert.NoError(t, err)
+ assert.NotEmpty(t, ciphertext)
+
+ // Decrypt should return empty plaintext
+ decrypted, err := svc.Decrypt(ciphertext)
+ assert.NoError(t, err)
+ assert.Empty(t, decrypted)
+}
+
+// TestDecrypt_InvalidCiphertext tests decryption error handling.
+func TestDecrypt_InvalidCiphertext(t *testing.T) {
+ key := make([]byte, 32)
+ _, err := rand.Read(key)
+ require.NoError(t, err)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ ciphertext string
+ errorMsg string
+ }{
+ {
+ name: "invalid base64",
+ ciphertext: "not-valid-base64!@#$",
+ errorMsg: "invalid base64 ciphertext",
+ },
+ {
+ name: "too short",
+ ciphertext: base64.StdEncoding.EncodeToString([]byte("short")),
+ errorMsg: "ciphertext too short",
+ },
+ {
+ name: "empty string",
+ ciphertext: "",
+ errorMsg: "ciphertext too short",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := svc.Decrypt(tt.ciphertext)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errorMsg)
+ })
+ }
+}
+
+// TestDecrypt_TamperedCiphertext tests that tampered ciphertext is detected.
+func TestDecrypt_TamperedCiphertext(t *testing.T) {
+ key := make([]byte, 32)
+ _, err := rand.Read(key)
+ require.NoError(t, err)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ require.NoError(t, err)
+
+ // Encrypt valid plaintext
+ original := "sensitive data"
+ ciphertext, err := svc.Encrypt([]byte(original))
+ require.NoError(t, err)
+
+ // Decode, tamper, and re-encode
+ ciphertextBytes, _ := base64.StdEncoding.DecodeString(ciphertext)
+ if len(ciphertextBytes) > 12 {
+ ciphertextBytes[12] ^= 0xFF // Flip bits in the middle
+ }
+ tamperedCiphertext := base64.StdEncoding.EncodeToString(ciphertextBytes)
+
+ // Attempt to decrypt tampered data
+ _, err = svc.Decrypt(tamperedCiphertext)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "decryption failed")
+}
+
+// TestEncrypt_DifferentNonces tests that multiple encryptions produce different ciphertexts.
+func TestEncrypt_DifferentNonces(t *testing.T) {
+ key := make([]byte, 32)
+ _, err := rand.Read(key)
+ require.NoError(t, err)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, err := NewEncryptionService(keyBase64)
+ require.NoError(t, err)
+
+ plaintext := []byte("test data")
+
+ // Encrypt the same plaintext multiple times
+ ciphertext1, err := svc.Encrypt(plaintext)
+ require.NoError(t, err)
+
+ ciphertext2, err := svc.Encrypt(plaintext)
+ require.NoError(t, err)
+
+ // Ciphertexts should be different (due to random nonces)
+ assert.NotEqual(t, ciphertext1, ciphertext2)
+
+ // But both should decrypt to the same plaintext
+ decrypted1, err := svc.Decrypt(ciphertext1)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted1)
+
+ decrypted2, err := svc.Decrypt(ciphertext2)
+ require.NoError(t, err)
+ assert.Equal(t, plaintext, decrypted2)
+}
+
+// TestDecrypt_WrongKey tests that decryption with wrong key fails.
+func TestDecrypt_WrongKey(t *testing.T) {
+ // Encrypt with first key
+ key1 := make([]byte, 32)
+ _, err := rand.Read(key1)
+ require.NoError(t, err)
+ keyBase64_1 := base64.StdEncoding.EncodeToString(key1)
+
+ svc1, err := NewEncryptionService(keyBase64_1)
+ require.NoError(t, err)
+
+ plaintext := "secret message"
+ ciphertext, err := svc1.Encrypt([]byte(plaintext))
+ require.NoError(t, err)
+
+ // Try to decrypt with different key
+ key2 := make([]byte, 32)
+ _, err = rand.Read(key2)
+ require.NoError(t, err)
+ keyBase64_2 := base64.StdEncoding.EncodeToString(key2)
+
+ svc2, err := NewEncryptionService(keyBase64_2)
+ require.NoError(t, err)
+
+ _, err = svc2.Decrypt(ciphertext)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "decryption failed")
+}
+
+// BenchmarkEncrypt benchmarks encryption performance.
+func BenchmarkEncrypt(b *testing.B) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, _ := NewEncryptionService(keyBase64)
+ plaintext := []byte("This is a test plaintext message for benchmarking encryption performance.")
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _ = svc.Encrypt(plaintext)
+ }
+}
+
+// BenchmarkDecrypt benchmarks decryption performance.
+func BenchmarkDecrypt(b *testing.B) {
+ key := make([]byte, 32)
+ _, _ = rand.Read(key)
+ keyBase64 := base64.StdEncoding.EncodeToString(key)
+
+ svc, _ := NewEncryptionService(keyBase64)
+ plaintext := []byte("This is a test plaintext message for benchmarking decryption performance.")
+ ciphertext, _ := svc.Encrypt(plaintext)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _ = svc.Decrypt(ciphertext)
+ }
+}
diff --git a/backend/internal/models/dns_provider.go b/backend/internal/models/dns_provider.go
new file mode 100644
index 00000000..ef98b622
--- /dev/null
+++ b/backend/internal/models/dns_provider.go
@@ -0,0 +1,38 @@
+// Package models defines the database schema and domain types.
+package models
+
+import (
+ "time"
+)
+
+// DNSProvider represents a DNS provider configuration for ACME DNS-01 challenges.
+// Credentials are stored encrypted at rest using AES-256-GCM.
+type DNSProvider struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
+ Name string `json:"name" gorm:"index;not null;size:255"`
+ ProviderType string `json:"provider_type" gorm:"index;not null;size:50"`
+ Enabled bool `json:"enabled" gorm:"default:true;index"`
+ IsDefault bool `json:"is_default" gorm:"default:false"`
+
+ // Encrypted credentials (JSON blob, encrypted with AES-256-GCM)
+ CredentialsEncrypted string `json:"-" gorm:"type:text;column:credentials_encrypted"`
+
+ // Propagation settings
+ PropagationTimeout int `json:"propagation_timeout" gorm:"default:120"` // seconds
+ PollingInterval int `json:"polling_interval" gorm:"default:5"` // seconds
+
+ // Usage tracking
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ SuccessCount int `json:"success_count" gorm:"default:0"`
+ FailureCount int `json:"failure_count" gorm:"default:0"`
+ LastError string `json:"last_error,omitempty" gorm:"type:text"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// TableName specifies the database table name.
+func (DNSProvider) TableName() string {
+ return "dns_providers"
+}
diff --git a/backend/internal/models/dns_provider_test.go b/backend/internal/models/dns_provider_test.go
new file mode 100644
index 00000000..01faf997
--- /dev/null
+++ b/backend/internal/models/dns_provider_test.go
@@ -0,0 +1,58 @@
+package models
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDNSProvider_TableName(t *testing.T) {
+ provider := DNSProvider{}
+ assert.Equal(t, "dns_providers", provider.TableName())
+}
+
+func TestDNSProvider_Fields(t *testing.T) {
+ provider := DNSProvider{
+ UUID: "test-uuid",
+ Name: "Test Provider",
+ ProviderType: "cloudflare",
+ Enabled: true,
+ IsDefault: false,
+ PropagationTimeout: 120,
+ PollingInterval: 5,
+ SuccessCount: 0,
+ FailureCount: 0,
+ }
+
+ assert.Equal(t, "test-uuid", provider.UUID)
+ assert.Equal(t, "Test Provider", provider.Name)
+ assert.Equal(t, "cloudflare", provider.ProviderType)
+ assert.True(t, provider.Enabled)
+ assert.False(t, provider.IsDefault)
+ assert.Equal(t, 120, provider.PropagationTimeout)
+ assert.Equal(t, 5, provider.PollingInterval)
+ assert.Equal(t, 0, provider.SuccessCount)
+ assert.Equal(t, 0, provider.FailureCount)
+}
+
+func TestDNSProvider_CredentialsEncrypted_NotSerialized(t *testing.T) {
+ // This test verifies that the CredentialsEncrypted field has the json:"-" tag
+ // by checking that it's not included in JSON serialization
+ provider := DNSProvider{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ CredentialsEncrypted: "encrypted-data-should-not-appear-in-json",
+ }
+
+ // Marshal to JSON
+ jsonData, err := json.Marshal(provider)
+ assert.NoError(t, err)
+
+ // Verify credentials are not in JSON
+ jsonString := string(jsonData)
+ assert.NotContains(t, jsonString, "credentials_encrypted")
+ assert.NotContains(t, jsonString, "encrypted-data-should-not-appear-in-json")
+ assert.Contains(t, jsonString, "Test")
+ assert.Contains(t, jsonString, "cloudflare")
+}
diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go
index f2175f58..fe75e58b 100644
--- a/backend/internal/models/proxy_host.go
+++ b/backend/internal/models/proxy_host.go
@@ -53,6 +53,11 @@ type ProxyHost struct {
// X-Forwarded-For is handled natively by Caddy (not explicitly set)
EnableStandardHeaders *bool `json:"enable_standard_headers,omitempty" gorm:"default:true"`
+ // DNS Challenge configuration
+ DNSProviderID *uint `json:"dns_provider_id,omitempty" gorm:"index"`
+ DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
+ UseDNSChallenge bool `json:"use_dns_challenge" gorm:"default:false"`
+
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
diff --git a/backend/internal/services/dns_provider_service.go b/backend/internal/services/dns_provider_service.go
new file mode 100644
index 00000000..2a2cbe08
--- /dev/null
+++ b/backend/internal/services/dns_provider_service.go
@@ -0,0 +1,420 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/Wikid82/charon/backend/internal/crypto"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+var (
+ // ErrDNSProviderNotFound is returned when a DNS provider is not found.
+ ErrDNSProviderNotFound = errors.New("dns provider not found")
+ // ErrInvalidProviderType is returned when an unsupported provider type is specified.
+ ErrInvalidProviderType = errors.New("invalid provider type")
+ // ErrInvalidCredentials is returned when required credentials are missing.
+ ErrInvalidCredentials = errors.New("invalid credentials: missing required fields")
+ // ErrEncryptionFailed is returned when credential encryption fails.
+ ErrEncryptionFailed = errors.New("failed to encrypt credentials")
+ // ErrDecryptionFailed is returned when credential decryption fails.
+ ErrDecryptionFailed = errors.New("failed to decrypt credentials")
+)
+
+// SupportedProviderTypes defines the list of supported DNS provider types.
+var SupportedProviderTypes = []string{
+ "cloudflare",
+ "route53",
+ "digitalocean",
+ "googleclouddns",
+ "namecheap",
+ "godaddy",
+ "azure",
+ "hetzner",
+ "vultr",
+ "dnsimple",
+}
+
+// ProviderCredentialFields maps provider types to their required credential fields.
+var ProviderCredentialFields = map[string][]string{
+ "cloudflare": {"api_token"},
+ "route53": {"access_key_id", "secret_access_key", "region"},
+ "digitalocean": {"auth_token"},
+ "googleclouddns": {"service_account_json", "project"},
+ "namecheap": {"api_user", "api_key", "client_ip"},
+ "godaddy": {"api_key", "api_secret"},
+ "azure": {"tenant_id", "client_id", "client_secret", "subscription_id", "resource_group"},
+ "hetzner": {"api_key"},
+ "vultr": {"api_key"},
+ "dnsimple": {"oauth_token", "account_id"},
+}
+
+// CreateDNSProviderRequest represents the request to create a new DNS provider.
+type CreateDNSProviderRequest struct {
+ Name string `json:"name" binding:"required"`
+ ProviderType string `json:"provider_type" binding:"required"`
+ Credentials map[string]string `json:"credentials" binding:"required"`
+ PropagationTimeout int `json:"propagation_timeout"`
+ PollingInterval int `json:"polling_interval"`
+ IsDefault bool `json:"is_default"`
+}
+
+// UpdateDNSProviderRequest represents the request to update an existing DNS provider.
+type UpdateDNSProviderRequest struct {
+ Name *string `json:"name"`
+ Credentials map[string]string `json:"credentials,omitempty"`
+ PropagationTimeout *int `json:"propagation_timeout"`
+ PollingInterval *int `json:"polling_interval"`
+ IsDefault *bool `json:"is_default"`
+ Enabled *bool `json:"enabled"`
+}
+
+// DNSProviderResponse represents the API response for a DNS provider.
+type DNSProviderResponse struct {
+ models.DNSProvider
+ HasCredentials bool `json:"has_credentials"`
+}
+
+// TestResult represents the result of testing DNS provider credentials.
+type TestResult struct {
+ Success bool `json:"success"`
+ Message string `json:"message,omitempty"`
+ Error string `json:"error,omitempty"`
+ Code string `json:"code,omitempty"`
+ PropagationTimeMs int64 `json:"propagation_time_ms,omitempty"`
+}
+
+// DNSProviderService provides operations for managing DNS providers.
+type DNSProviderService interface {
+ List(ctx context.Context) ([]models.DNSProvider, error)
+ Get(ctx context.Context, id uint) (*models.DNSProvider, error)
+ Create(ctx context.Context, req CreateDNSProviderRequest) (*models.DNSProvider, error)
+ Update(ctx context.Context, id uint, req UpdateDNSProviderRequest) (*models.DNSProvider, error)
+ Delete(ctx context.Context, id uint) error
+ Test(ctx context.Context, id uint) (*TestResult, error)
+ TestCredentials(ctx context.Context, req CreateDNSProviderRequest) (*TestResult, error)
+ GetDecryptedCredentials(ctx context.Context, id uint) (map[string]string, error)
+}
+
+// dnsProviderService implements the DNSProviderService interface.
+type dnsProviderService struct {
+ db *gorm.DB
+ encryptor *crypto.EncryptionService
+}
+
+// NewDNSProviderService creates a new DNS provider service.
+func NewDNSProviderService(db *gorm.DB, encryptor *crypto.EncryptionService) DNSProviderService {
+ return &dnsProviderService{
+ db: db,
+ encryptor: encryptor,
+ }
+}
+
+// List retrieves all DNS providers.
+func (s *dnsProviderService) List(ctx context.Context) ([]models.DNSProvider, error) {
+ var providers []models.DNSProvider
+ err := s.db.WithContext(ctx).Order("is_default DESC, name ASC").Find(&providers).Error
+ return providers, err
+}
+
+// Get retrieves a DNS provider by ID.
+func (s *dnsProviderService) Get(ctx context.Context, id uint) (*models.DNSProvider, error) {
+ var provider models.DNSProvider
+ err := s.db.WithContext(ctx).First(&provider, id).Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrDNSProviderNotFound
+ }
+ return nil, err
+ }
+ return &provider, nil
+}
+
+// Create creates a new DNS provider with encrypted credentials.
+func (s *dnsProviderService) Create(ctx context.Context, req CreateDNSProviderRequest) (*models.DNSProvider, error) {
+ // Validate provider type
+ if !isValidProviderType(req.ProviderType) {
+ return nil, ErrInvalidProviderType
+ }
+
+ // Validate required credentials
+ if err := validateCredentials(req.ProviderType, req.Credentials); err != nil {
+ return nil, err
+ }
+
+ // Encrypt credentials
+ credentialsJSON, err := json.Marshal(req.Credentials)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
+ }
+
+ encryptedCreds, err := s.encryptor.Encrypt(credentialsJSON)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
+ }
+
+ // Set defaults
+ propagationTimeout := req.PropagationTimeout
+ if propagationTimeout == 0 {
+ propagationTimeout = 120
+ }
+
+ pollingInterval := req.PollingInterval
+ if pollingInterval == 0 {
+ pollingInterval = 5
+ }
+
+ // Handle default provider logic
+ if req.IsDefault {
+ // Unset any existing default provider
+ if err := s.db.WithContext(ctx).Model(&models.DNSProvider{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
+ return nil, err
+ }
+ }
+
+ // Create provider
+ provider := &models.DNSProvider{
+ UUID: uuid.New().String(),
+ Name: req.Name,
+ ProviderType: req.ProviderType,
+ CredentialsEncrypted: encryptedCreds,
+ PropagationTimeout: propagationTimeout,
+ PollingInterval: pollingInterval,
+ IsDefault: req.IsDefault,
+ Enabled: true,
+ }
+
+ if err := s.db.WithContext(ctx).Create(provider).Error; err != nil {
+ return nil, err
+ }
+
+ return provider, nil
+}
+
+// Update updates an existing DNS provider.
+func (s *dnsProviderService) Update(ctx context.Context, id uint, req UpdateDNSProviderRequest) (*models.DNSProvider, error) {
+ // Fetch existing provider
+ provider, err := s.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Update fields if provided
+ if req.Name != nil {
+ provider.Name = *req.Name
+ }
+
+ if req.PropagationTimeout != nil {
+ provider.PropagationTimeout = *req.PropagationTimeout
+ }
+
+ if req.PollingInterval != nil {
+ provider.PollingInterval = *req.PollingInterval
+ }
+
+ if req.Enabled != nil {
+ provider.Enabled = *req.Enabled
+ }
+
+ // Handle credentials update
+ if req.Credentials != nil && len(req.Credentials) > 0 {
+ // Validate credentials
+ if err := validateCredentials(provider.ProviderType, req.Credentials); err != nil {
+ return nil, err
+ }
+
+ // Encrypt new credentials
+ credentialsJSON, err := json.Marshal(req.Credentials)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
+ }
+
+ encryptedCreds, err := s.encryptor.Encrypt(credentialsJSON)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
+ }
+
+ provider.CredentialsEncrypted = encryptedCreds
+ }
+
+ // Handle default provider logic
+ if req.IsDefault != nil && *req.IsDefault {
+ // Unset any existing default provider
+ if err := s.db.WithContext(ctx).Model(&models.DNSProvider{}).Where("is_default = ? AND id != ?", true, id).Update("is_default", false).Error; err != nil {
+ return nil, err
+ }
+ provider.IsDefault = true
+ } else if req.IsDefault != nil && !*req.IsDefault {
+ provider.IsDefault = false
+ }
+
+ // Save updates
+ if err := s.db.WithContext(ctx).Save(provider).Error; err != nil {
+ return nil, err
+ }
+
+ return provider, nil
+}
+
+// Delete deletes a DNS provider.
+func (s *dnsProviderService) Delete(ctx context.Context, id uint) error {
+ result := s.db.WithContext(ctx).Delete(&models.DNSProvider{}, id)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return ErrDNSProviderNotFound
+ }
+ return nil
+}
+
+// Test tests a saved DNS provider's credentials.
+func (s *dnsProviderService) Test(ctx context.Context, id uint) (*TestResult, error) {
+ provider, err := s.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Decrypt credentials
+ credentials, err := s.GetDecryptedCredentials(ctx, id)
+ if err != nil {
+ return &TestResult{
+ Success: false,
+ Error: "Failed to decrypt credentials",
+ Code: "DECRYPTION_ERROR",
+ }, nil
+ }
+
+ // Perform test
+ result := testDNSProviderCredentials(provider.ProviderType, credentials)
+
+ // Update provider statistics
+ now := time.Now()
+ provider.LastUsedAt = &now
+
+ if result.Success {
+ provider.SuccessCount++
+ provider.LastError = ""
+ } else {
+ provider.FailureCount++
+ provider.LastError = result.Error
+ }
+
+ // Save statistics (ignore errors to avoid failing the test operation)
+ _ = s.db.WithContext(ctx).Save(provider)
+
+ return result, nil
+}
+
+// TestCredentials tests DNS provider credentials without saving them.
+func (s *dnsProviderService) TestCredentials(ctx context.Context, req CreateDNSProviderRequest) (*TestResult, error) {
+ // Validate provider type
+ if !isValidProviderType(req.ProviderType) {
+ return &TestResult{
+ Success: false,
+ Error: "Unsupported provider type",
+ Code: "INVALID_PROVIDER_TYPE",
+ }, nil
+ }
+
+ // Validate credentials
+ if err := validateCredentials(req.ProviderType, req.Credentials); err != nil {
+ return &TestResult{
+ Success: false,
+ Error: err.Error(),
+ Code: "INVALID_CREDENTIALS",
+ }, nil
+ }
+
+ // Perform test
+ return testDNSProviderCredentials(req.ProviderType, req.Credentials), nil
+}
+
+// GetDecryptedCredentials retrieves and decrypts a DNS provider's credentials.
+func (s *dnsProviderService) GetDecryptedCredentials(ctx context.Context, id uint) (map[string]string, error) {
+ provider, err := s.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Decrypt credentials
+ decryptedData, err := s.encryptor.Decrypt(provider.CredentialsEncrypted)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
+ }
+
+ // Parse JSON
+ var credentials map[string]string
+ if err := json.Unmarshal(decryptedData, &credentials); err != nil {
+ return nil, fmt.Errorf("%w: invalid credential format", ErrDecryptionFailed)
+ }
+
+ // Update last used timestamp
+ now := time.Now()
+ provider.LastUsedAt = &now
+ _ = s.db.WithContext(ctx).Save(provider)
+
+ return credentials, nil
+}
+
+// isValidProviderType checks if a provider type is supported.
+func isValidProviderType(providerType string) bool {
+ for _, supported := range SupportedProviderTypes {
+ if providerType == supported {
+ return true
+ }
+ }
+ return false
+}
+
+// validateCredentials validates that all required credential fields are present.
+func validateCredentials(providerType string, credentials map[string]string) error {
+ requiredFields, ok := ProviderCredentialFields[providerType]
+ if !ok {
+ return ErrInvalidProviderType
+ }
+
+ // Check for required fields
+ for _, field := range requiredFields {
+ if value, exists := credentials[field]; !exists || value == "" {
+ return fmt.Errorf("%w: missing field '%s'", ErrInvalidCredentials, field)
+ }
+ }
+
+ return nil
+}
+
+// testDNSProviderCredentials performs a basic validation test on DNS provider credentials.
+// In a real implementation, this would make actual API calls to the DNS provider.
+// For now, we simulate the test with basic validation.
+func testDNSProviderCredentials(providerType string, credentials map[string]string) *TestResult {
+ // Simulate validation logic
+ // In production, this would make actual API calls to verify credentials
+
+ startTime := time.Now()
+
+ // Basic validation - check if credentials have the expected structure
+ if err := validateCredentials(providerType, credentials); err != nil {
+ return &TestResult{
+ Success: false,
+ Error: err.Error(),
+ Code: "VALIDATION_ERROR",
+ }
+ }
+
+ // Simulate API call delay
+ elapsed := time.Since(startTime).Milliseconds()
+
+ // For now, return success if validation passed
+ // TODO: Implement actual API calls to DNS providers
+ return &TestResult{
+ Success: true,
+ Message: "DNS provider credentials validated successfully (basic validation only)",
+ PropagationTimeMs: elapsed,
+ }
+}
diff --git a/backend/internal/services/dns_provider_service_test.go b/backend/internal/services/dns_provider_service_test.go
new file mode 100644
index 00000000..d2e2b807
--- /dev/null
+++ b/backend/internal/services/dns_provider_service_test.go
@@ -0,0 +1,767 @@
+package services
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/crypto"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+// setupTestDB creates an in-memory SQLite database for testing.
+func setupDNSProviderTestDB(t *testing.T) (*gorm.DB, *crypto.EncryptionService) {
+ t.Helper()
+
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ require.NoError(t, err)
+
+ // Auto-migrate schema
+ err = db.AutoMigrate(&models.DNSProvider{})
+ require.NoError(t, err)
+
+ // Create encryption service with test key
+ encryptor, err := crypto.NewEncryptionService("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 32-byte key in base64
+ require.NoError(t, err)
+
+ return db, encryptor
+}
+
+func TestDNSProviderService_Create(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ tests := []struct {
+ name string
+ req CreateDNSProviderRequest
+ wantErr bool
+ expectedErr error
+ }{
+ {
+ name: "valid cloudflare provider",
+ req: CreateDNSProviderRequest{
+ Name: "Test Cloudflare",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{
+ "api_token": "test-token-123",
+ },
+ PropagationTimeout: 120,
+ PollingInterval: 5,
+ IsDefault: true,
+ },
+ wantErr: false,
+ },
+ {
+ name: "valid route53 provider with defaults",
+ req: CreateDNSProviderRequest{
+ Name: "Test Route53",
+ ProviderType: "route53",
+ Credentials: map[string]string{
+ "access_key_id": "AKIAIOSFODNN7EXAMPLE",
+ "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+ "region": "us-east-1",
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid provider type",
+ req: CreateDNSProviderRequest{
+ Name: "Invalid Provider",
+ ProviderType: "invalid",
+ Credentials: map[string]string{
+ "api_key": "test",
+ },
+ },
+ wantErr: true,
+ expectedErr: ErrInvalidProviderType,
+ },
+ {
+ name: "missing required credentials",
+ req: CreateDNSProviderRequest{
+ Name: "Incomplete Cloudflare",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{},
+ },
+ wantErr: true,
+ expectedErr: ErrInvalidCredentials,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ provider, err := service.Create(ctx, tt.req)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ if tt.expectedErr != nil {
+ assert.ErrorIs(t, err, tt.expectedErr)
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ assert.NotZero(t, provider.ID)
+ assert.NotEmpty(t, provider.UUID)
+ assert.Equal(t, tt.req.Name, provider.Name)
+ assert.Equal(t, tt.req.ProviderType, provider.ProviderType)
+ assert.True(t, provider.Enabled)
+ assert.NotEmpty(t, provider.CredentialsEncrypted)
+
+ // Verify defaults were set
+ if tt.req.PropagationTimeout == 0 {
+ assert.Equal(t, 120, provider.PropagationTimeout)
+ }
+ if tt.req.PollingInterval == 0 {
+ assert.Equal(t, 5, provider.PollingInterval)
+ }
+
+ // Verify credentials are encrypted (not plaintext)
+ assert.NotContains(t, provider.CredentialsEncrypted, "api_token")
+ assert.NotContains(t, provider.CredentialsEncrypted, "test-token")
+ })
+ }
+}
+
+func TestDNSProviderService_DefaultProviderLogic(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create first default provider
+ provider1, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "First Default",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{
+ "api_token": "token1",
+ },
+ IsDefault: true,
+ })
+ require.NoError(t, err)
+ assert.True(t, provider1.IsDefault)
+
+ // Create second default provider - should unset first
+ provider2, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Second Default",
+ ProviderType: "route53",
+ Credentials: map[string]string{
+ "access_key_id": "key",
+ "secret_access_key": "secret",
+ "region": "us-east-1",
+ },
+ IsDefault: true,
+ })
+ require.NoError(t, err)
+ assert.True(t, provider2.IsDefault)
+
+ // Verify first provider is no longer default
+ updatedProvider1, err := service.Get(ctx, provider1.ID)
+ require.NoError(t, err)
+ assert.False(t, updatedProvider1.IsDefault)
+}
+
+func TestDNSProviderService_List(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create multiple providers
+ _, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Cloudflare",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+
+ _, err = service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Route53",
+ ProviderType: "route53",
+ Credentials: map[string]string{
+ "access_key_id": "key",
+ "secret_access_key": "secret",
+ "region": "us-east-1",
+ },
+ IsDefault: true,
+ })
+ require.NoError(t, err)
+
+ // List all providers
+ providers, err := service.List(ctx)
+ require.NoError(t, err)
+ assert.Len(t, providers, 2)
+
+ // Verify default provider is first (ordered by is_default DESC)
+ assert.True(t, providers[0].IsDefault)
+}
+
+func TestDNSProviderService_Get(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create a provider
+ created, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test Provider",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+
+ // Get the provider
+ provider, err := service.Get(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, created.ID, provider.ID)
+ assert.Equal(t, "Test Provider", provider.Name)
+
+ // Get non-existent provider
+ _, err = service.Get(ctx, 9999)
+ assert.ErrorIs(t, err, ErrDNSProviderNotFound)
+}
+
+func TestDNSProviderService_Update(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create a provider
+ created, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Original Name",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "original-token"},
+ })
+ require.NoError(t, err)
+
+ t.Run("update name only", func(t *testing.T) {
+ newName := "Updated Name"
+ updated, err := service.Update(ctx, created.ID, UpdateDNSProviderRequest{
+ Name: &newName,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, "Updated Name", updated.Name)
+ assert.True(t, updated.Enabled) // Should remain unchanged
+ })
+
+ t.Run("update credentials", func(t *testing.T) {
+ newCreds := map[string]string{"api_token": "new-token"}
+ updated, err := service.Update(ctx, created.ID, UpdateDNSProviderRequest{
+ Credentials: newCreds,
+ })
+ require.NoError(t, err)
+
+ // Verify credentials were updated by decrypting
+ decrypted, err := service.GetDecryptedCredentials(ctx, updated.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "new-token", decrypted["api_token"])
+ })
+
+ t.Run("update enabled status", func(t *testing.T) {
+ enabled := false
+ updated, err := service.Update(ctx, created.ID, UpdateDNSProviderRequest{
+ Enabled: &enabled,
+ })
+ require.NoError(t, err)
+ assert.False(t, updated.Enabled)
+ })
+
+ t.Run("update non-existent provider", func(t *testing.T) {
+ name := "Test"
+ _, err := service.Update(ctx, 9999, UpdateDNSProviderRequest{
+ Name: &name,
+ })
+ assert.ErrorIs(t, err, ErrDNSProviderNotFound)
+ })
+
+ t.Run("update to set default", func(t *testing.T) {
+ isDefault := true
+ updated, err := service.Update(ctx, created.ID, UpdateDNSProviderRequest{
+ IsDefault: &isDefault,
+ })
+ require.NoError(t, err)
+ assert.True(t, updated.IsDefault)
+ })
+}
+
+func TestDNSProviderService_Delete(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create a provider
+ created, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "To Delete",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+
+ // Delete the provider
+ err = service.Delete(ctx, created.ID)
+ require.NoError(t, err)
+
+ // Verify it's deleted
+ _, err = service.Get(ctx, created.ID)
+ assert.ErrorIs(t, err, ErrDNSProviderNotFound)
+
+ // Delete non-existent provider
+ err = service.Delete(ctx, 9999)
+ assert.ErrorIs(t, err, ErrDNSProviderNotFound)
+}
+
+func TestDNSProviderService_GetDecryptedCredentials(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create a provider
+ testCreds := map[string]string{
+ "api_token": "secret-token-123",
+ "extra": "data",
+ }
+ created, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test Provider",
+ ProviderType: "cloudflare",
+ Credentials: testCreds,
+ })
+ require.NoError(t, err)
+
+ // Get decrypted credentials
+ decrypted, err := service.GetDecryptedCredentials(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, testCreds, decrypted)
+
+ // Verify last_used_at was updated
+ provider, err := service.Get(ctx, created.ID)
+ require.NoError(t, err)
+ assert.NotNil(t, provider.LastUsedAt)
+}
+
+func TestDNSProviderService_TestCredentials(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ t.Run("valid credentials", func(t *testing.T) {
+ result, err := service.TestCredentials(ctx, CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+ assert.NotEmpty(t, result.Message)
+ })
+
+ t.Run("invalid provider type", func(t *testing.T) {
+ result, err := service.TestCredentials(ctx, CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "invalid",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+ assert.Equal(t, "INVALID_PROVIDER_TYPE", result.Code)
+ })
+
+ t.Run("missing credentials", func(t *testing.T) {
+ result, err := service.TestCredentials(ctx, CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "route53",
+ Credentials: map[string]string{"access_key_id": "key"}, // Missing secret and region
+ })
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+ assert.Equal(t, "INVALID_CREDENTIALS", result.Code)
+ })
+}
+
+func TestDNSProviderService_Test(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create a provider
+ created, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test Provider",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+
+ // Test the provider
+ result, err := service.Test(ctx, created.ID)
+ require.NoError(t, err)
+ assert.True(t, result.Success)
+
+ // Verify statistics were updated
+ provider, err := service.Get(ctx, created.ID)
+ require.NoError(t, err)
+ assert.Equal(t, 1, provider.SuccessCount)
+ assert.Equal(t, 0, provider.FailureCount)
+ assert.NotNil(t, provider.LastUsedAt)
+ assert.Empty(t, provider.LastError)
+}
+
+func TestValidateCredentials(t *testing.T) {
+ tests := []struct {
+ name string
+ providerType string
+ credentials map[string]string
+ wantErr bool
+ }{
+ {
+ name: "valid cloudflare",
+ providerType: "cloudflare",
+ credentials: map[string]string{"api_token": "token"},
+ wantErr: false,
+ },
+ {
+ name: "valid route53",
+ providerType: "route53",
+ credentials: map[string]string{
+ "access_key_id": "key",
+ "secret_access_key": "secret",
+ "region": "us-east-1",
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing field",
+ providerType: "route53",
+ credentials: map[string]string{
+ "access_key_id": "key",
+ // Missing secret_access_key and region
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty field value",
+ providerType: "cloudflare",
+ credentials: map[string]string{"api_token": ""},
+ wantErr: true,
+ },
+ {
+ name: "invalid provider type",
+ providerType: "invalid",
+ credentials: map[string]string{"api_token": "token"},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateCredentials(tt.providerType, tt.credentials)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestIsValidProviderType(t *testing.T) {
+ tests := []struct {
+ name string
+ providerType string
+ want bool
+ }{
+ {"cloudflare", "cloudflare", true},
+ {"route53", "route53", true},
+ {"digitalocean", "digitalocean", true},
+ {"invalid", "invalid", false},
+ {"empty", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, isValidProviderType(tt.providerType))
+ })
+ }
+}
+
+func TestCredentialEncryptionRoundtrip(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ originalCreds := map[string]string{
+ "api_token": "super-secret-token",
+ "api_key": "another-secret",
+ "extra_data": "sensitive",
+ }
+
+ // Create provider with credentials
+ provider, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Encryption Test",
+ ProviderType: "cloudflare",
+ Credentials: originalCreds,
+ })
+ require.NoError(t, err)
+
+ // Verify credentials are encrypted in database
+ var dbProvider models.DNSProvider
+ err = db.First(&dbProvider, provider.ID).Error
+ require.NoError(t, err)
+ assert.NotContains(t, dbProvider.CredentialsEncrypted, "super-secret-token")
+ assert.NotContains(t, dbProvider.CredentialsEncrypted, "another-secret")
+
+ // Decrypt and verify
+ decrypted, err := service.GetDecryptedCredentials(ctx, provider.ID)
+ require.NoError(t, err)
+ assert.Equal(t, originalCreds, decrypted)
+}
+
+func TestUpdatePreservesCredentials(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ originalCreds := map[string]string{"api_token": "original-token"}
+
+ // Create provider
+ provider, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: originalCreds,
+ })
+ require.NoError(t, err)
+
+ // Update without providing credentials
+ newName := "Updated Name"
+ updated, err := service.Update(ctx, provider.ID, UpdateDNSProviderRequest{
+ Name: &newName,
+ })
+ require.NoError(t, err)
+
+ // Verify credentials were preserved
+ decrypted, err := service.GetDecryptedCredentials(ctx, updated.ID)
+ require.NoError(t, err)
+ assert.Equal(t, originalCreds, decrypted)
+}
+
+func TestEncryptionServiceIntegration(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+
+ testData := map[string]string{
+ "key1": "value1",
+ "key2": "value2",
+ }
+
+ // Encrypt
+ jsonData, err := json.Marshal(testData)
+ require.NoError(t, err)
+
+ encrypted, err := encryptor.Encrypt(jsonData)
+ require.NoError(t, err)
+ assert.NotEmpty(t, encrypted)
+
+ // Store in database
+ provider := &models.DNSProvider{
+ UUID: "test-uuid",
+ Name: "Test",
+ ProviderType: "cloudflare",
+ CredentialsEncrypted: encrypted,
+ }
+ err = db.Create(provider).Error
+ require.NoError(t, err)
+
+ // Retrieve and decrypt
+ var retrieved models.DNSProvider
+ err = db.First(&retrieved, provider.ID).Error
+ require.NoError(t, err)
+
+ decrypted, err := encryptor.Decrypt(retrieved.CredentialsEncrypted)
+ require.NoError(t, err)
+
+ var result map[string]string
+ err = json.Unmarshal(decrypted, &result)
+ require.NoError(t, err)
+ assert.Equal(t, testData, result)
+}
+
+func TestDNSProviderService_TestFailure(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create provider with invalid credentials structure (will fail decryption in real scenario)
+ provider := &models.DNSProvider{
+ UUID: "test-uuid",
+ Name: "Test",
+ ProviderType: "cloudflare",
+ CredentialsEncrypted: "invalid-encrypted-data",
+ }
+ err := db.Create(provider).Error
+ require.NoError(t, err)
+
+ // Test should handle decryption failure gracefully
+ result, err := service.Test(ctx, provider.ID)
+ require.NoError(t, err)
+ assert.False(t, result.Success)
+ assert.Equal(t, "DECRYPTION_ERROR", result.Code)
+}
+
+func TestDNSProviderService_GetDecryptedCredentialsError(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create provider with invalid encrypted data
+ provider := &models.DNSProvider{
+ UUID: "test-uuid",
+ Name: "Test",
+ ProviderType: "cloudflare",
+ CredentialsEncrypted: "not-valid-base64",
+ }
+ err := db.Create(provider).Error
+ require.NoError(t, err)
+
+ // Should fail to decrypt
+ _, err = service.GetDecryptedCredentials(ctx, provider.ID)
+ assert.Error(t, err)
+ assert.ErrorIs(t, err, ErrDecryptionFailed)
+}
+
+func TestDNSProviderService_UpdateDefaultLogic(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create two providers, first is default
+ provider1, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Provider 1",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token1"},
+ IsDefault: true,
+ })
+ require.NoError(t, err)
+
+ provider2, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Provider 2",
+ ProviderType: "route53",
+ Credentials: map[string]string{
+ "access_key_id": "key",
+ "secret_access_key": "secret",
+ "region": "us-east-1",
+ },
+ })
+ require.NoError(t, err)
+
+ // Make provider2 default via update
+ isDefault := true
+ _, err = service.Update(ctx, provider2.ID, UpdateDNSProviderRequest{
+ IsDefault: &isDefault,
+ })
+ require.NoError(t, err)
+
+ // Verify provider1 is no longer default
+ updated1, err := service.Get(ctx, provider1.ID)
+ require.NoError(t, err)
+ assert.False(t, updated1.IsDefault)
+
+ // Verify provider2 is default
+ updated2, err := service.Get(ctx, provider2.ID)
+ require.NoError(t, err)
+ assert.True(t, updated2.IsDefault)
+
+ // Unset default
+ notDefault := false
+ _, err = service.Update(ctx, provider2.ID, UpdateDNSProviderRequest{
+ IsDefault: ¬Default,
+ })
+ require.NoError(t, err)
+
+ updated2, err = service.Get(ctx, provider2.ID)
+ require.NoError(t, err)
+ assert.False(t, updated2.IsDefault)
+}
+
+func TestAllProviderTypes(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Test all supported provider types
+ testCases := map[string]map[string]string{
+ "cloudflare": {"api_token": "token"},
+ "route53": {"access_key_id": "key", "secret_access_key": "secret", "region": "us-east-1"},
+ "digitalocean": {"auth_token": "token"},
+ "googleclouddns": {"service_account_json": "{}", "project": "test-project"},
+ "namecheap": {"api_user": "user", "api_key": "key", "client_ip": "1.2.3.4"},
+ "godaddy": {"api_key": "key", "api_secret": "secret"},
+ "azure": {
+ "tenant_id": "tenant",
+ "client_id": "client",
+ "client_secret": "secret",
+ "subscription_id": "sub",
+ "resource_group": "rg",
+ },
+ "hetzner": {"api_key": "key"},
+ "vultr": {"api_key": "key"},
+ "dnsimple": {"oauth_token": "token", "account_id": "12345"},
+ }
+
+ for providerType, creds := range testCases {
+ t.Run(providerType, func(t *testing.T) {
+ provider, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test " + providerType,
+ ProviderType: providerType,
+ Credentials: creds,
+ })
+ require.NoError(t, err, "Failed to create %s provider", providerType)
+ assert.Equal(t, providerType, provider.ProviderType)
+
+ // Verify credentials can be decrypted
+ decrypted, err := service.GetDecryptedCredentials(ctx, provider.ID)
+ require.NoError(t, err)
+ assert.Equal(t, creds, decrypted)
+ })
+ }
+}
+
+func TestDNSProviderService_UpdateInvalidCredentials(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create a provider
+ provider, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "token"},
+ })
+ require.NoError(t, err)
+
+ // Try to update with invalid credentials
+ invalidCreds := map[string]string{"wrong_field": "value"}
+ _, err = service.Update(ctx, provider.ID, UpdateDNSProviderRequest{
+ Credentials: invalidCreds,
+ })
+ assert.Error(t, err)
+ assert.ErrorIs(t, err, ErrInvalidCredentials)
+}
+
+func TestDNSProviderService_CreateEncryptionError(t *testing.T) {
+ db, encryptor := setupDNSProviderTestDB(t)
+ service := NewDNSProviderService(db, encryptor)
+ ctx := context.Background()
+
+ // Create with credentials that would marshal to invalid JSON
+ // This is hard to test without mocking, so we test the encryption path by
+ // verifying that any errors during encryption are properly wrapped
+ provider, err := service.Create(ctx, CreateDNSProviderRequest{
+ Name: "Test",
+ ProviderType: "cloudflare",
+ Credentials: map[string]string{"api_token": "valid-token"},
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, provider.CredentialsEncrypted)
+}
diff --git a/docs/guides/dns-providers.md b/docs/guides/dns-providers.md
new file mode 100644
index 00000000..ae8d77b9
--- /dev/null
+++ b/docs/guides/dns-providers.md
@@ -0,0 +1,171 @@
+# DNS Providers Guide
+
+## Overview
+
+DNS providers enable Charon to obtain SSL/TLS certificates for wildcard domains (e.g., `*.example.com`) using the ACME DNS-01 challenge. This challenge proves domain ownership by creating a temporary TXT record in your DNS zone, which is required for wildcard certificates since HTTP-01 challenges cannot validate wildcards.
+
+## Why DNS Providers Are Required
+
+- **Wildcard Certificates:** ACME providers (like Let's Encrypt) require DNS-01 challenges for wildcard domains
+- **Automated Validation:** Charon automatically creates and removes DNS records during certificate issuance
+- **Secure Storage:** All credentials are encrypted at rest using AES-256-GCM encryption
+
+## Supported DNS Providers
+
+Charon supports the following DNS providers through Caddy's libdns modules:
+
+| Provider | Type | Setup Guide |
+|----------|------|-------------|
+| Cloudflare | `cloudflare` | [Cloudflare Setup](dns-providers/cloudflare.md) |
+| AWS Route 53 | `route53` | [Route 53 Setup](dns-providers/route53.md) |
+| DigitalOcean | `digitalocean` | [DigitalOcean Setup](dns-providers/digitalocean.md) |
+| Google Cloud DNS | `googleclouddns` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.googleclouddns) |
+| Azure DNS | `azure` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.azure) |
+| Namecheap | `namecheap` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.namecheap) |
+| GoDaddy | `godaddy` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.godaddy) |
+| Hetzner | `hetzner` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.hetzner) |
+| Vultr | `vultr` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.vultr) |
+| DNSimple | `dnsimple` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.dnsimple) |
+
+## General Setup Workflow
+
+### 1. Prerequisites
+
+- Active account with a supported DNS provider
+- Domain's DNS hosted with the provider
+- API access enabled on your account
+- Generated API credentials (tokens, keys, etc.)
+
+### 2. Configure Encryption Key
+
+DNS provider credentials are encrypted at rest. Before adding providers, ensure the encryption key is configured:
+
+```bash
+# Generate a 32-byte (256-bit) random key and encode as base64
+openssl rand -base64 32
+
+# Set as environment variable
+export CHARON_ENCRYPTION_KEY="your-base64-encoded-key-here"
+```
+
+> **Warning:** The encryption key must be 32 bytes (44 characters in base64). Store it securely and back it up. If lost, you'll need to reconfigure all DNS providers.
+
+Add to your Docker Compose or systemd configuration:
+
+```yaml
+# docker-compose.yml
+services:
+ charon:
+ environment:
+ - CHARON_ENCRYPTION_KEY=${CHARON_ENCRYPTION_KEY}
+```
+
+### 3. Add DNS Provider
+
+1. Navigate to **DNS Providers** in the Charon UI
+2. Click **Add Provider**
+3. Select your DNS provider type
+4. Enter a descriptive name (e.g., "Cloudflare Production")
+5. Fill in the required credentials
+6. (Optional) Adjust propagation timeout and polling interval
+7. Click **Test Connection** to verify credentials
+8. Click **Save**
+
+### 4. Set Default Provider (Optional)
+
+If you manage multiple domains across different DNS providers, you can designate one as the default. This will be pre-selected when creating new wildcard proxy hosts.
+
+### 5. Create Wildcard Proxy Host
+
+1. Navigate to **Proxy Hosts**
+2. Click **Add Proxy Host**
+3. Enter a wildcard domain (e.g., `*.example.com`)
+4. Select your DNS provider from the dropdown
+5. Configure other settings as needed
+6. Save the proxy host
+
+Charon will automatically use DNS-01 challenge for certificate issuance.
+
+## Security Best Practices
+
+### Credential Management
+
+- **Least Privilege:** Create API tokens with minimum required permissions (DNS zone edit only)
+- **Scope Tokens:** Limit tokens to specific DNS zones when supported by the provider
+- **Rotate Regularly:** Periodically regenerate API tokens
+- **Secure Storage:** Never commit credentials to version control
+
+### Encryption Key
+
+- **Backup:** Store the `CHARON_ENCRYPTION_KEY` in a secure password manager
+- **Environment Variable:** Never hardcode the key in configuration files
+- **Rotate Carefully:** Changing the key requires reconfiguring all DNS providers
+
+### Network Security
+
+- **Firewall Rules:** Ensure Charon can reach DNS provider APIs (typically HTTPS outbound)
+- **Monitor Access:** Review API access logs in your DNS provider dashboard
+
+## Configuration Options
+
+### Propagation Timeout
+
+Time (in seconds) to wait for DNS changes to propagate before ACME validation. Default: **120 seconds**.
+
+- **Increase** if you experience validation failures due to slow DNS propagation
+- **Decrease** if your DNS provider has fast global propagation (e.g., Cloudflare)
+
+### Polling Interval
+
+Time (in seconds) between checks for DNS record propagation. Default: **10 seconds**.
+
+- Most users should keep the default value
+- Adjust if hitting DNS provider API rate limits
+
+## Troubleshooting
+
+For detailed troubleshooting, see [DNS Challenges Troubleshooting](../troubleshooting/dns-challenges.md).
+
+### Common Issues
+
+**"Encryption key not configured"**
+- Ensure `CHARON_ENCRYPTION_KEY` environment variable is set
+- Restart Charon after setting the variable
+
+**"Connection test failed"**
+- Verify credentials are correct
+- Check API token permissions
+- Ensure firewall allows outbound HTTPS to provider
+- Review provider-specific troubleshooting guides
+
+**"DNS propagation timeout"**
+- Increase propagation timeout in provider settings
+- Verify DNS provider is authoritative for the domain
+- Check provider status page for service issues
+
+**"Certificate issuance failed"**
+- Test DNS provider connection in UI
+- Check Charon logs for detailed error messages
+- Verify domain DNS is properly configured
+- Ensure DNS provider has edit permissions for the zone
+
+## Provider-Specific Guides
+
+- [Cloudflare Setup Guide](dns-providers/cloudflare.md)
+- [AWS Route 53 Setup Guide](dns-providers/route53.md)
+- [DigitalOcean Setup Guide](dns-providers/digitalocean.md)
+
+For other providers, consult the official Caddy libdns module documentation linked in the table above.
+
+## Related Documentation
+
+- [Certificates Guide](certificates.md)
+- [Proxy Hosts Guide](proxy-hosts.md)
+- [DNS Challenges Troubleshooting](../troubleshooting/dns-challenges.md)
+- [Security Best Practices](../security/best-practices.md)
+
+## Additional Resources
+
+- [Let's Encrypt DNS-01 Challenge Documentation](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)
+- [Caddy DNS Providers](https://caddyserver.com/docs/modules/)
+- [ACME Protocol Specification](https://datatracker.ietf.org/doc/html/rfc8555)
diff --git a/docs/guides/dns-providers/cloudflare.md b/docs/guides/dns-providers/cloudflare.md
new file mode 100644
index 00000000..81bf54f1
--- /dev/null
+++ b/docs/guides/dns-providers/cloudflare.md
@@ -0,0 +1,160 @@
+# Cloudflare DNS Provider Setup
+
+## Overview
+
+Cloudflare is one of the most popular DNS providers and offers a free tier with API access. This guide walks you through setting up Cloudflare as a DNS provider in Charon for wildcard certificate support.
+
+## Prerequisites
+
+- Active Cloudflare account (free tier is sufficient)
+- Domain added to Cloudflare with nameservers configured
+- Domain status: **Active** (not pending nameserver update)
+
+## Step 1: Generate API Token
+
+Cloudflare API Tokens provide scoped access and are more secure than Global API Keys.
+
+1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com/)
+2. Click on your profile icon (top right) → **My Profile**
+3. Select **API Tokens** from the left sidebar
+4. Click **Create Token**
+5. Use the **Edit zone DNS** template or create a custom token
+6. Configure token permissions:
+ - **Permissions:**
+ - Zone → DNS → Edit
+ - **Zone Resources:**
+ - Include → Specific zone → Select your domain
+ - OR Include → All zones (if managing multiple domains)
+7. (Optional) Set **Client IP Address Filtering** for additional security
+8. (Optional) Set **TTL** for token expiration
+9. Click **Continue to summary**
+10. Review permissions and click **Create Token**
+11. **Copy the token immediately** (shown only once)
+
+> **Tip:** Store the API token in a password manager. Cloudflare won't display it again.
+
+## Step 2: Configure in Charon
+
+1. Navigate to **DNS Providers** in Charon
+2. Click **Add Provider**
+3. Fill in the form:
+ - **Provider Type:** Select `Cloudflare`
+ - **Name:** Enter a descriptive name (e.g., "Cloudflare Production")
+ - **API Token:** Paste the token from Step 1
+
+### Advanced Settings (Optional)
+
+Expand **Advanced Settings** to customize:
+
+- **Propagation Timeout:** `60` seconds (Cloudflare has fast global propagation)
+- **Polling Interval:** `10` seconds (default)
+- **Set as Default:** Enable if this is your primary DNS provider
+
+## Step 3: Test Connection
+
+1. Click **Test Connection** button
+2. Wait for validation (usually 2-5 seconds)
+3. Verify you see: ✅ **Connection successful**
+
+If the test fails, see [Troubleshooting](#troubleshooting) below.
+
+## Step 4: Save Configuration
+
+Click **Save** to store the DNS provider configuration. Credentials are encrypted at rest using AES-256-GCM.
+
+## Step 5: Use with Wildcard Certificates
+
+When creating a proxy host with a wildcard domain:
+
+1. Navigate to **Proxy Hosts** → **Add Proxy Host**
+2. Enter a wildcard domain: `*.example.com`
+3. Select **Cloudflare** from the DNS Provider dropdown
+4. Configure remaining settings
+5. Save
+
+Charon will automatically obtain a wildcard certificate using DNS-01 challenge.
+
+## Example Configuration
+
+```yaml
+Provider Type: cloudflare
+Name: Cloudflare - example.com
+API Token: ********************************
+Propagation Timeout: 60 seconds
+Polling Interval: 10 seconds
+Default: Yes
+```
+
+## Required Permissions
+
+The API token needs the following Cloudflare permissions:
+
+- **Zone → DNS → Edit:** Create and delete TXT records for ACME challenges
+
+> **Note:** The token does NOT need Zone → Edit or Account-level permissions.
+
+## Troubleshooting
+
+### Connection Test Fails
+
+**Error:** `Invalid API token`
+
+- Verify the token was copied correctly (no extra spaces)
+- Ensure the token has Zone → DNS → Edit permission
+- Check token hasn't expired (if TTL was set)
+- Regenerate the token if necessary
+
+**Error:** `Zone not found`
+
+- Verify the domain is added to your Cloudflare account
+- Ensure domain status is **Active** (nameservers updated)
+- Check API token includes the correct zone in Zone Resources
+
+### Certificate Issuance Fails
+
+**Error:** `DNS propagation timeout`
+
+- Cloudflare typically propagates in <30 seconds
+- Check Cloudflare Status page for service issues
+- Verify DNSSEC is configured correctly (if enabled)
+- Try increasing Propagation Timeout to 120 seconds
+
+**Error:** `Unauthorized to edit DNS`
+
+- API token may have been revoked
+- Regenerate a new token with correct permissions
+- Update configuration in Charon
+
+### Rate Limiting
+
+Cloudflare has generous API rate limits:
+
+- Free plan: 1,200 requests per 5 minutes
+- Certificate challenges typically use <10 requests
+
+If you hit limits:
+
+- Reduce polling frequency
+- Avoid unnecessary test connection attempts
+- Consider upgrading Cloudflare plan
+
+## Security Recommendations
+
+1. **Scope Tokens:** Limit to specific zones rather than "All zones"
+2. **IP Filtering:** Add your server's IP to Client IP Address Filtering
+3. **Set Expiration:** Use token TTL for automatic expiration (renew before expiry)
+4. **Rotate Regularly:** Generate new tokens every 90-180 days
+5. **Monitor Usage:** Review API token activity in Cloudflare dashboard
+
+## Additional Resources
+
+- [Cloudflare API Documentation](https://developers.cloudflare.com/api/)
+- [API Token Permissions](https://developers.cloudflare.com/api/tokens/create/)
+- [Caddy Cloudflare Module](https://caddyserver.com/docs/modules/dns.providers.cloudflare)
+- [Cloudflare Status Page](https://www.cloudflarestatus.com/)
+
+## Related Documentation
+
+- [DNS Providers Overview](../dns-providers.md)
+- [Wildcard Certificates Guide](../certificates.md#wildcard-certificates)
+- [DNS Challenges Troubleshooting](../../troubleshooting/dns-challenges.md)
diff --git a/docs/guides/dns-providers/digitalocean.md b/docs/guides/dns-providers/digitalocean.md
new file mode 100644
index 00000000..b691ae4e
--- /dev/null
+++ b/docs/guides/dns-providers/digitalocean.md
@@ -0,0 +1,195 @@
+# DigitalOcean DNS Provider Setup
+
+## Overview
+
+DigitalOcean provides DNS hosting for free with any DigitalOcean account. This guide covers setting up DigitalOcean DNS as a provider in Charon for wildcard certificate management.
+
+## Prerequisites
+
+- DigitalOcean account (free tier is sufficient)
+- Domain added to DigitalOcean DNS
+- Domain nameservers pointing to DigitalOcean:
+ - `ns1.digitalocean.com`
+ - `ns2.digitalocean.com`
+ - `ns3.digitalocean.com`
+
+## Step 1: Generate Personal Access Token
+
+1. Log in to [DigitalOcean Control Panel](https://cloud.digitalocean.com/)
+2. Click on **API** in the left sidebar (under Account)
+3. Navigate to the **Tokens/Keys** tab
+4. Click **Generate New Token** (in the Personal access tokens section)
+5. Configure the token:
+ - **Token Name:** `charon-dns-challenge` (or any descriptive name)
+ - **Expiration:** Choose expiration period (90 days, 1 year, or no expiry)
+ - **Scopes:** Select **Write** (this includes Read access)
+6. Click **Generate Token**
+7. **Copy the token immediately** (shown only once)
+
+> **Warning:** DigitalOcean shows the token only once. Store it securely in a password manager.
+
+## Step 2: Verify DNS Configuration
+
+Ensure your domain is properly configured in DigitalOcean DNS:
+
+1. Navigate to **Networking** → **Domains** in the DigitalOcean control panel
+2. Verify your domain is listed
+3. Click on the domain to view DNS records
+4. Ensure at least one A or CNAME record exists (for the domain itself)
+
+> **Note:** Charon will create and remove TXT records automatically; no manual DNS configuration is needed.
+
+## Step 3: Configure in Charon
+
+1. Navigate to **DNS Providers** in Charon
+2. Click **Add Provider**
+3. Fill in the form:
+ - **Provider Type:** Select `DigitalOcean`
+ - **Name:** Enter a descriptive name (e.g., "DigitalOcean DNS")
+ - **API Token:** Paste the Personal Access Token from Step 1
+
+### Advanced Settings (Optional)
+
+Expand **Advanced Settings** to customize:
+
+- **Propagation Timeout:** `90` seconds (DigitalOcean propagates quickly)
+- **Polling Interval:** `10` seconds (default)
+- **Set as Default:** Enable if this is your primary DNS provider
+
+## Step 4: Test Connection
+
+1. Click **Test Connection** button
+2. Wait for validation (usually 3-5 seconds)
+3. Verify you see: ✅ **Connection successful**
+
+The test verifies:
+- Token is valid and active
+- Account has DNS write permissions
+- DigitalOcean API is accessible
+
+If the test fails, see [Troubleshooting](#troubleshooting) below.
+
+## Step 5: Save Configuration
+
+Click **Save** to store the DNS provider configuration. The token is encrypted at rest using AES-256-GCM.
+
+## Step 6: Use with Wildcard Certificates
+
+When creating a proxy host with a wildcard domain:
+
+1. Navigate to **Proxy Hosts** → **Add Proxy Host**
+2. Enter a wildcard domain: `*.example.com`
+3. Select **DigitalOcean** from the DNS Provider dropdown
+4. Configure remaining settings
+5. Save
+
+Charon will automatically obtain a wildcard certificate using DNS-01 challenge.
+
+## Example Configuration
+
+```yaml
+Provider Type: digitalocean
+Name: DigitalOcean - example.com
+API Token: dop_v1_********************************
+Propagation Timeout: 90 seconds
+Polling Interval: 10 seconds
+Default: Yes
+```
+
+## Required Permissions
+
+The Personal Access Token needs **Write** scope, which includes:
+
+- Read access to domains and DNS records
+- Write access to create/update/delete DNS records
+
+> **Note:** Token scope is account-wide. You cannot restrict to specific domains in DigitalOcean.
+
+## Troubleshooting
+
+### Connection Test Fails
+
+**Error:** `Invalid token` or `Unauthorized`
+
+- Verify the token was copied correctly (should start with `dop_v1_`)
+- Ensure token has **Write** scope (not just Read)
+- Check token hasn't expired (if expiration was set)
+- Regenerate the token if necessary
+
+**Error:** `Domain not found`
+
+- Verify the domain is added to DigitalOcean DNS
+- Ensure domain nameservers point to DigitalOcean
+- Check domain status in the Networking section
+- Wait 24-48 hours if nameservers were recently changed
+
+### Certificate Issuance Fails
+
+**Error:** `DNS propagation timeout`
+
+- DigitalOcean DNS typically propagates in <60 seconds
+- Verify nameservers are correctly configured:
+ ```bash
+ dig NS example.com +short
+ ```
+- Check DigitalOcean Status page for service issues
+- Increase Propagation Timeout to 120 seconds as a workaround
+
+**Error:** `Record creation failed`
+
+- Check token permissions (must be Write scope)
+- Verify domain exists in DigitalOcean DNS
+- Review Charon logs for detailed API errors
+- Ensure no conflicting TXT records exist with name `_acme-challenge`
+
+### Nameserver Propagation
+
+**Issue:** DNS changes not visible globally
+
+- Nameserver changes can take 24-48 hours to propagate
+- Use [DNS Checker](https://dnschecker.org/) to verify global propagation
+- Ensure your domain registrar shows DigitalOcean nameservers
+- Wait for full propagation before attempting certificate issuance
+
+### Rate Limiting
+
+DigitalOcean API rate limits:
+
+- 5,000 requests per hour (per account)
+- Certificate challenges typically use <20 requests
+
+If you hit limits:
+
+- Reduce frequency of certificate renewals
+- Avoid unnecessary test connection attempts
+- Contact DigitalOcean support if consistently hitting limits
+
+## Security Recommendations
+
+1. **Token Expiration:** Set 90-day expiration and rotate regularly
+2. **Dedicated Token:** Create a separate token for Charon (easier to revoke)
+3. **Monitor Usage:** Review API logs in DigitalOcean control panel
+4. **Least Privilege:** Use Write scope (don't grant Full Access)
+5. **Backup Access:** Keep a backup token in secure storage (offline)
+6. **Revoke Unused:** Delete tokens that are no longer needed
+
+## DigitalOcean DNS Limitations
+
+- **No per-domain token scoping:** Tokens grant access to all domains in the account
+- **No rate limit customization:** Fixed at 5,000 requests/hour
+- **Public zones only:** Private DNS not supported
+- **No DNSSEC:** DigitalOcean does not support DNSSEC at this time
+
+## Additional Resources
+
+- [DigitalOcean DNS Documentation](https://docs.digitalocean.com/products/networking/dns/)
+- [DigitalOcean API Documentation](https://docs.digitalocean.com/reference/api/)
+- [Personal Access Tokens Guide](https://docs.digitalocean.com/reference/api/create-personal-access-token/)
+- [Caddy DigitalOcean Module](https://caddyserver.com/docs/modules/dns.providers.digitalocean)
+- [DigitalOcean Status Page](https://status.digitalocean.com/)
+
+## Related Documentation
+
+- [DNS Providers Overview](../dns-providers.md)
+- [Wildcard Certificates Guide](../certificates.md#wildcard-certificates)
+- [DNS Challenges Troubleshooting](../../troubleshooting/dns-challenges.md)
diff --git a/docs/guides/dns-providers/route53.md b/docs/guides/dns-providers/route53.md
new file mode 100644
index 00000000..9fb2a660
--- /dev/null
+++ b/docs/guides/dns-providers/route53.md
@@ -0,0 +1,236 @@
+# AWS Route 53 DNS Provider Setup
+
+## Overview
+
+Amazon Route 53 is AWS's scalable DNS service. This guide covers setting up Route 53 as a DNS provider in Charon for wildcard certificate management.
+
+## Prerequisites
+
+- AWS account with Route 53 access
+- Domain hosted in Route 53 (public hosted zone)
+- IAM permissions to create users and policies
+- AWS CLI (optional, for verification)
+
+## Step 1: Create IAM Policy
+
+Create a custom IAM policy with minimum required permissions:
+
+1. Log in to [AWS Console](https://console.aws.amazon.com/)
+2. Navigate to **IAM** → **Policies**
+3. Click **Create Policy**
+4. Select **JSON** tab
+5. Paste the following policy:
+
+```json
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "route53:ListHostedZones",
+ "route53:GetChange"
+ ],
+ "Resource": "*"
+ },
+ {
+ "Effect": "Allow",
+ "Action": [
+ "route53:ChangeResourceRecordSets"
+ ],
+ "Resource": "arn:aws:route53:::hostedzone/*"
+ }
+ ]
+}
+```
+
+6. Click **Next: Tags** (optional tags)
+7. Click **Next: Review**
+8. **Name:** `CharonRoute53DNSChallenge`
+9. **Description:** `Allows Charon to manage DNS TXT records for ACME challenges`
+10. Click **Create Policy**
+
+> **Tip:** For production, scope the policy to specific hosted zones by replacing `*` with your zone ID.
+
+## Step 2: Create IAM User
+
+Create a dedicated IAM user for Charon:
+
+1. Navigate to **IAM** → **Users**
+2. Click **Add Users**
+3. **User name:** `charon-dns`
+4. Select **Access key - Programmatic access**
+5. Click **Next: Permissions**
+6. Select **Attach existing policies directly**
+7. Search for and select `CharonRoute53DNSChallenge`
+8. Click **Next: Tags** (optional)
+9. Click **Next: Review**
+10. Click **Create User**
+11. **Save the credentials** (shown only once):
+ - Access Key ID
+ - Secret Access Key
+
+> **Warning:** Download the CSV or copy credentials immediately. AWS won't show the secret again.
+
+## Step 3: Configure in Charon
+
+1. Navigate to **DNS Providers** in Charon
+2. Click **Add Provider**
+3. Fill in the form:
+ - **Provider Type:** Select `AWS Route 53`
+ - **Name:** Enter a descriptive name (e.g., "AWS Route 53 - Production")
+ - **AWS Access Key ID:** Paste the access key from Step 2
+ - **AWS Secret Access Key:** Paste the secret key from Step 2
+ - **AWS Region:** (Optional) Specify region (default: `us-east-1`)
+
+### Advanced Settings (Optional)
+
+Expand **Advanced Settings** to customize:
+
+- **Propagation Timeout:** `120` seconds (Route 53 propagation can take 60-120 seconds)
+- **Polling Interval:** `10` seconds (default)
+- **Set as Default:** Enable if this is your primary DNS provider
+
+## Step 4: Test Connection
+
+1. Click **Test Connection** button
+2. Wait for validation (may take 5-10 seconds)
+3. Verify you see: ✅ **Connection successful**
+
+The test verifies:
+- Credentials are valid
+- IAM user has required permissions
+- Route 53 hosted zones are accessible
+
+If the test fails, see [Troubleshooting](#troubleshooting) below.
+
+## Step 5: Save Configuration
+
+Click **Save** to store the DNS provider configuration. Credentials are encrypted at rest using AES-256-GCM.
+
+## Step 6: Use with Wildcard Certificates
+
+When creating a proxy host with a wildcard domain:
+
+1. Navigate to **Proxy Hosts** → **Add Proxy Host**
+2. Enter a wildcard domain: `*.example.com`
+3. Select **AWS Route 53** from the DNS Provider dropdown
+4. Configure remaining settings
+5. Save
+
+Charon will automatically obtain a wildcard certificate using DNS-01 challenge.
+
+## Example Configuration
+
+```yaml
+Provider Type: route53
+Name: AWS Route 53 - example.com
+Access Key ID: AKIAIOSFODNN7EXAMPLE
+Secret Access Key: ****************************************
+Region: us-east-1
+Propagation Timeout: 120 seconds
+Polling Interval: 10 seconds
+Default: Yes
+```
+
+## Required IAM Permissions
+
+The IAM user needs the following Route 53 permissions:
+
+| Action | Resource | Purpose |
+|--------|----------|---------|
+| `route53:ListHostedZones` | `*` | List available hosted zones |
+| `route53:GetChange` | `*` | Check status of DNS changes |
+| `route53:ChangeResourceRecordSets` | `arn:aws:route53:::hostedzone/*` | Create/delete TXT records for challenges |
+
+> **Security Best Practice:** Scope `ChangeResourceRecordSets` to specific hosted zone ARNs:
+
+```json
+"Resource": "arn:aws:route53:::hostedzone/Z1234567890ABC"
+```
+
+## Troubleshooting
+
+### Connection Test Fails
+
+**Error:** `Invalid credentials`
+
+- Verify Access Key ID and Secret Access Key were copied correctly
+- Check IAM user exists and is active
+- Ensure no extra spaces or characters in credentials
+
+**Error:** `Access denied`
+
+- Verify IAM policy is attached to the user
+- Check policy includes all required permissions
+- Review CloudTrail logs for denied API calls
+
+**Error:** `Hosted zone not found`
+
+- Ensure domain has a public hosted zone in Route 53
+- Verify hosted zone is in the same AWS account
+- Check zone is not private (private zones not supported)
+
+### Certificate Issuance Fails
+
+**Error:** `DNS propagation timeout`
+
+- Route 53 propagation typically takes 60-120 seconds
+- Increase Propagation Timeout to 180 seconds
+- Verify hosted zone is authoritative for the domain
+- Check Route 53 name servers match domain registrar settings
+
+**Error:** `Rate limit exceeded`
+
+- Route 53 has API rate limits (5 requests/second per account)
+- Increase Polling Interval to 15-20 seconds
+- Avoid concurrent certificate requests
+- Contact AWS support to increase limits
+
+### Region Configuration
+
+**Issue:** Specifying the wrong region
+
+- Route 53 is a global service; region typically doesn't matter
+- Use `us-east-1` (default) if unsure
+- Some endpoints may require specific regions
+- Check Charon logs if region-specific errors occur
+
+## Security Recommendations
+
+1. **IAM User:** Create a dedicated user for Charon (don't reuse credentials)
+2. **Least Privilege:** Use the minimal policy provided above
+3. **Scope to Zones:** Limit policy to specific hosted zones in production
+4. **Rotate Keys:** Rotate access keys every 90 days
+5. **Monitor Usage:** Enable CloudTrail for API activity auditing
+6. **MFA Protection:** Enable MFA on the AWS account (not the IAM user)
+7. **Access Advisor:** Review IAM Access Advisor to ensure permissions are used
+
+## AWS CLI Verification (Optional)
+
+Test credentials before adding to Charon:
+
+```bash
+# Configure AWS CLI with credentials
+aws configure --profile charon-dns
+
+# List hosted zones
+aws route53 list-hosted-zones --profile charon-dns
+
+# Verify permissions
+aws iam get-user --profile charon-dns
+```
+
+## Additional Resources
+
+- [AWS Route 53 Documentation](https://docs.aws.amazon.com/route53/)
+- [IAM Best Practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html)
+- [Route 53 API Reference](https://docs.aws.amazon.com/route53/latest/APIReference/)
+- [Caddy Route 53 Module](https://caddyserver.com/docs/modules/dns.providers.route53)
+- [AWS CloudTrail](https://console.aws.amazon.com/cloudtrail/)
+
+## Related Documentation
+
+- [DNS Providers Overview](../dns-providers.md)
+- [Wildcard Certificates Guide](../certificates.md#wildcard-certificates)
+- [DNS Challenges Troubleshooting](../../troubleshooting/dns-challenges.md)
diff --git a/docs/plans/dns_challenge_backend_research.md b/docs/plans/dns_challenge_backend_research.md
new file mode 100644
index 00000000..c4b904cd
--- /dev/null
+++ b/docs/plans/dns_challenge_backend_research.md
@@ -0,0 +1,552 @@
+# DNS Challenge Backend Research - Issue #21
+
+## Executive Summary
+
+This document analyzes the Charon backend to understand how to implement DNS challenge support for wildcard certificates. The research covers current Caddy integration, existing model patterns, and proposes new models, API endpoints, and encryption strategies.
+
+---
+
+## 1. Current Caddy Integration Analysis
+
+### 1.1 Configuration Generation Flow
+
+The Caddy configuration is generated in [backend/internal/caddy/config.go](../../backend/internal/caddy/config.go).
+
+**Key function:** `GenerateConfig()` (line 17)
+
+```go
+func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string,
+ acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool,
+ adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string,
+ decisions []models.SecurityDecision, secCfg *models.SecurityConfig) (*Config, error)
+```
+
+### 1.2 Current TLS/ACME Configuration
+
+**Location:** [config.go#L66-L105](../../backend/internal/caddy/config.go#L66-L105)
+
+Current SSL provider handling:
+- `letsencrypt` - ACME with Let's Encrypt
+- `zerossl` - ZeroSSL module
+- `both`/default - Both issuers as fallback
+
+**Current Issuer Configuration:**
+```go
+switch sslProvider {
+case "letsencrypt":
+ acmeIssuer := map[string]any{
+ "module": "acme",
+ "email": acmeEmail,
+ }
+ if acmeStaging {
+ acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
+ }
+ issuers = append(issuers, acmeIssuer)
+// ...
+}
+```
+
+### 1.3 TLS App Structure
+
+**Location:** [backend/internal/caddy/types.go#L172-L199](../../backend/internal/caddy/types.go#L172-L199)
+
+```go
+type TLSApp struct {
+ Automation *AutomationConfig `json:"automation,omitempty"`
+ Certificates *CertificatesConfig `json:"certificates,omitempty"`
+}
+
+type AutomationConfig struct {
+ Policies []*AutomationPolicy `json:"policies,omitempty"`
+}
+
+type AutomationPolicy struct {
+ Subjects []string `json:"subjects,omitempty"`
+ IssuersRaw []any `json:"issuers,omitempty"`
+}
+```
+
+### 1.4 Config Application Flow
+
+**Manager:** [backend/internal/caddy/manager.go](../../backend/internal/caddy/manager.go)
+
+1. `ApplyConfig()` fetches proxy hosts from DB
+2. Reads settings (ACME email, SSL provider)
+3. Calls `GenerateConfig()` to build Caddy JSON
+4. Validates configuration
+5. Saves snapshot for rollback
+6. Applies via Caddy admin API
+
+---
+
+## 2. Existing Model Patterns
+
+### 2.1 Model Structure Convention
+
+All models follow this pattern:
+
+| Field | Type | Purpose |
+|-------|------|---------|
+| `ID` | `uint` | Primary key |
+| `UUID` | `string` | External identifier |
+| `Name` | `string` | Human-readable name |
+| `Enabled` | `bool` | Active state |
+| `CreatedAt`/`UpdatedAt` | `time.Time` | Timestamps |
+
+### 2.2 Relevant Existing Models
+
+#### SSLCertificate ([ssl_certificate.go](../../backend/internal/models/ssl_certificate.go))
+```go
+type SSLCertificate struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ UUID string `json:"uuid" gorm:"uniqueIndex"`
+ Name string `json:"name" gorm:"index"`
+ Provider string `json:"provider" gorm:"index"` // "letsencrypt", "custom", "self-signed"
+ Domains string `json:"domains" gorm:"index"` // comma-separated
+ Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded
+ PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded
+ ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
+ AutoRenew bool `json:"auto_renew" gorm:"default:false"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+#### SecurityConfig ([security_config.go](../../backend/internal/models/security_config.go))
+- Stores global security settings
+- Uses `gorm:"type:text"` for JSON blobs
+- Has sensitive field (`BreakGlassHash`) with `json:"-"` tag
+
+#### Setting ([setting.go](../../backend/internal/models/setting.go))
+```go
+type Setting struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ Key string `json:"key" gorm:"uniqueIndex"`
+ Value string `json:"value" gorm:"type:text"`
+ Type string `json:"type" gorm:"index"` // "string", "int", "bool", "json"
+ Category string `json:"category" gorm:"index"` // grouping
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+#### NotificationProvider ([notification_provider.go](../../backend/internal/models/notification_provider.go))
+- Stores webhook URLs and configs
+- **Currently does NOT encrypt sensitive data** (URLs stored as plaintext)
+- Uses JSON config for flexible provider-specific data
+
+### 2.3 Password/Secret Handling Pattern
+
+**Location:** [backend/internal/models/user.go](../../backend/internal/models/user.go)
+
+Uses bcrypt for password hashing:
+```go
+func (u *User) SetPassword(password string) error {
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ // ...
+}
+```
+
+**Important:** Bcrypt is one-way hashing (good for passwords). DNS credentials need **reversible encryption** (AES-GCM).
+
+---
+
+## 3. Proposed New Models
+
+### 3.1 DNSProvider Model
+
+```go
+// DNSProvider represents a DNS provider configuration for ACME DNS-01 challenges.
+// Used for wildcard certificate issuance via DNS validation.
+type DNSProvider struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ UUID string `json:"uuid" gorm:"uniqueIndex"`
+ Name string `json:"name" gorm:"index;not null"` // User-friendly name
+ ProviderType string `json:"provider_type" gorm:"index;not null"` // cloudflare, route53, godaddy, etc.
+ Enabled bool `json:"enabled" gorm:"default:true;index"`
+
+ // Encrypted credentials stored as JSON blob
+ // Contains provider-specific fields (api_key, api_secret, zone_id, etc.)
+ CredentialsEncrypted string `json:"-" gorm:"type:text;column:credentials_encrypted"`
+
+ // Propagation settings (DNS record TTL considerations)
+ PropagationTimeout int `json:"propagation_timeout" gorm:"default:120"` // seconds
+ PollingInterval int `json:"polling_interval" gorm:"default:5"` // seconds
+
+ // Usage tracking
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ SuccessCount int `json:"success_count" gorm:"default:0"`
+ FailureCount int `json:"failure_count" gorm:"default:0"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+### 3.2 DNSProviderCredential (Alternative: Separate Table)
+
+If we want to support multiple credential sets per provider (e.g., different zones):
+
+```go
+// DNSProviderCredential stores encrypted credentials for a DNS provider.
+type DNSProviderCredential struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ UUID string `json:"uuid" gorm:"uniqueIndex"`
+ DNSProviderID uint `json:"dns_provider_id" gorm:"index;not null"`
+ DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
+
+ Label string `json:"label" gorm:"index"` // "Production Zone", "Dev Account"
+ ZoneID string `json:"zone_id,omitempty"` // Optional zone restriction
+
+ // Encrypted credential blob
+ EncryptedData string `json:"-" gorm:"type:text;not null"`
+
+ // Key derivation metadata (for key rotation)
+ KeyVersion int `json:"key_version" gorm:"default:1"`
+ EncryptedAt time.Time `json:"encrypted_at"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+### 3.3 Supported Provider Types
+
+| Provider | Required Credentials | Caddy Module |
+|----------|---------------------|--------------|
+| Cloudflare | `api_token` or `api_key` + `email` | `cloudflare` |
+| Route53 (AWS) | `access_key_id`, `secret_access_key`, `region` | `route53` |
+| Google Cloud DNS | `service_account_json` | `googleclouddns` |
+| DigitalOcean | `auth_token` | `digitalocean` |
+| Namecheap | `api_user`, `api_key`, `client_ip` | `namecheap` |
+| GoDaddy | `api_key`, `api_secret` | `godaddy` |
+| Hetzner | `api_key` | `hetzner` |
+| Vultr | `api_key` | `vultr` |
+| DNSimple | `oauth_token`, `account_id` | `dnsimple` |
+| Azure DNS | `tenant_id`, `client_id`, `client_secret`, `subscription_id`, `resource_group` | `azuredns` |
+
+---
+
+## 4. API Endpoint Design
+
+### 4.1 DNS Provider Management
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `GET` | `/api/v1/dns-providers` | List all DNS providers |
+| `POST` | `/api/v1/dns-providers` | Create new DNS provider |
+| `GET` | `/api/v1/dns-providers/:id` | Get provider details |
+| `PUT` | `/api/v1/dns-providers/:id` | Update provider |
+| `DELETE` | `/api/v1/dns-providers/:id` | Delete provider |
+| `POST` | `/api/v1/dns-providers/:id/test` | Test DNS provider connectivity |
+| `GET` | `/api/v1/dns-providers/types` | List supported provider types with required fields |
+
+### 4.2 Request/Response Examples
+
+#### Create DNS Provider Request
+```json
+{
+ "name": "My Cloudflare Account",
+ "provider_type": "cloudflare",
+ "credentials": {
+ "api_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+ },
+ "propagation_timeout": 120,
+ "polling_interval": 5
+}
+```
+
+#### List DNS Providers Response
+```json
+{
+ "providers": [
+ {
+ "id": 1,
+ "uuid": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "My Cloudflare Account",
+ "provider_type": "cloudflare",
+ "enabled": true,
+ "has_credentials": true,
+ "propagation_timeout": 120,
+ "polling_interval": 5,
+ "last_used_at": "2026-01-01T10:30:00Z",
+ "success_count": 15,
+ "failure_count": 0,
+ "created_at": "2025-12-01T08:00:00Z"
+ }
+ ]
+}
+```
+
+### 4.3 Integration with Proxy Host
+
+Extend `ProxyHost` model:
+
+```go
+type ProxyHost struct {
+ // ... existing fields ...
+
+ // DNS Challenge configuration
+ DNSProviderID *uint `json:"dns_provider_id,omitempty" gorm:"index"`
+ DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
+ UseDNSChallenge bool `json:"use_dns_challenge" gorm:"default:false"`
+}
+```
+
+---
+
+## 5. Encryption Strategy
+
+### 5.1 Recommended Approach: AES-256-GCM
+
+**Why AES-GCM:**
+- Authenticated encryption (provides confidentiality + integrity)
+- Standard and well-vetted
+- Fast on modern CPUs with AES-NI
+- Used by industry standards (TLS 1.3, Google, AWS KMS)
+
+### 5.2 Implementation Plan
+
+#### New Package: `backend/internal/crypto/`
+
+```go
+// crypto/encryption.go
+package crypto
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "io"
+)
+
+// EncryptionService handles credential encryption/decryption
+type EncryptionService struct {
+ key []byte // 32 bytes for AES-256
+}
+
+// NewEncryptionService creates a service with the provided key
+func NewEncryptionService(keyBase64 string) (*EncryptionService, error) {
+ key, err := base64.StdEncoding.DecodeString(keyBase64)
+ if err != nil || len(key) != 32 {
+ return nil, errors.New("invalid encryption key: must be 32 bytes base64 encoded")
+ }
+ return &EncryptionService{key: key}, nil
+}
+
+// Encrypt encrypts plaintext using AES-256-GCM
+func (s *EncryptionService) Encrypt(plaintext []byte) (string, error) {
+ block, err := aes.NewCipher(s.key)
+ if err != nil {
+ return "", err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+
+ ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+// Decrypt decrypts ciphertext using AES-256-GCM
+func (s *EncryptionService) Decrypt(ciphertextB64 string) ([]byte, error) {
+ ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
+ if err != nil {
+ return nil, err
+ }
+
+ block, err := aes.NewCipher(s.key)
+ if err != nil {
+ return nil, err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+
+ nonceSize := gcm.NonceSize()
+ if len(ciphertext) < nonceSize {
+ return nil, errors.New("ciphertext too short")
+ }
+
+ nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
+ return gcm.Open(nil, nonce, ciphertext, nil)
+}
+```
+
+### 5.3 Key Management
+
+#### Environment Variable
+```bash
+CHARON_ENCRYPTION_KEY=
+ {t(`dnsProviders.types.${provider.provider_type}`, provider.provider_type)}
+ {t('dnsProviders.lastUsed')}
+ {provider.last_used_at
+ ? formatDistanceToNow(new Date(provider.last_used_at), { addSuffix: true })
+ : t('dnsProviders.neverUsed')}
+ {t('dnsProviders.successRate')}
+ {provider.success_count} / {provider.failure_count}
+ {t('dnsProviders.propagationTimeout')} {provider.propagation_timeout}s {t('dnsProviders.pollingInterval')} {provider.polling_interval}s {t('dnsProviders.lastError')} {provider.last_error}
+ {error}
+ {helperText}
Wildcard Certificate Required
++ Wildcard certificates (*.example.com) require DNS-01 challenge. + Select a DNS provider to automatically manage DNS records for certificate validation. +
+