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= +``` + +#### Key Generation (one-time setup) +```bash +openssl rand -base64 32 +``` + +#### Configuration Extension + +```go +// config/config.go +type Config struct { + // ... existing fields ... + EncryptionKey string // From CHARON_ENCRYPTION_KEY +} +``` + +### 5.4 Security Considerations + +1. **Key Storage:** Encryption key MUST be stored securely (env var, secrets manager) +2. **Key Rotation:** Include `KeyVersion` field for future key rotation support +3. **Memory Safety:** Zero out decrypted credentials after use where possible +4. **Audit Logging:** Log access to encrypted credentials (without logging the values) +5. **Backup Encryption:** Ensure database backups don't expose plaintext credentials + +--- + +## 6. Files to Create/Modify + +### 6.1 New Files to Create + +| File | Purpose | +|------|---------| +| `backend/internal/crypto/encryption.go` | AES-GCM encryption service | +| `backend/internal/crypto/encryption_test.go` | Encryption unit tests | +| `backend/internal/models/dns_provider.go` | DNSProvider model | +| `backend/internal/services/dns_provider_service.go` | DNS provider CRUD + credential handling | +| `backend/internal/services/dns_provider_service_test.go` | Service unit tests | +| `backend/internal/api/handlers/dns_provider_handler.go` | API handlers | +| `backend/internal/api/handlers/dns_provider_handler_test.go` | Handler tests | + +### 6.2 Files to Modify + +| File | Changes | +|------|---------| +| `backend/internal/config/config.go` | Add `EncryptionKey` field | +| `backend/internal/models/proxy_host.go` | Add `DNSProviderID`, `UseDNSChallenge` fields | +| `backend/internal/caddy/types.go` | Add DNS challenge issuer types | +| `backend/internal/caddy/config.go` | Add DNS challenge configuration generation | +| `backend/internal/caddy/manager.go` | Load DNS providers when applying config | +| `backend/internal/api/routes/routes.go` | Register DNS provider routes | +| `backend/internal/api/handlers/proxyhost_handler.go` | Handle DNS provider association | + +--- + +## 7. Caddy DNS Challenge Configuration + +### 7.1 Target Caddy JSON Structure + +For DNS-01 challenges, the TLS automation policy needs a `challenges` block: + +```json +{ + "apps": { + "tls": { + "automation": { + "policies": [ + { + "subjects": ["*.example.com", "example.com"], + "issuers": [ + { + "module": "acme", + "email": "admin@example.com", + "challenges": { + "dns": { + "provider": { + "name": "cloudflare", + "api_token": "{env.CF_API_TOKEN}" + }, + "propagation_timeout": 120000000000, + "resolvers": ["1.1.1.1:53"] + } + } + } + ] + } + ] + } + } + } +} +``` + +### 7.2 New Caddy Types + +```go +// types.go additions + +// 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"` +} + +// Update AutomationPolicy +type AutomationPolicy struct { + Subjects []string `json:"subjects,omitempty"` + IssuersRaw []any `json:"issuers,omitempty"` + // Note: Challenges are configured per-issuer in IssuersRaw +} +``` + +--- + +## 8. Implementation Phases + +### Phase 1: Foundation +1. Create encryption package +2. Create DNSProvider model +3. Database migration + +### Phase 2: Service Layer +1. DNS provider service (CRUD) +2. Credential encryption/decryption +3. Provider connectivity testing + +### Phase 3: API Layer +1. DNS provider handlers +2. Route registration +3. API validation + +### Phase 4: Caddy Integration +1. Update config generation +2. DNS challenge issuer building +3. ProxyHost integration + +### Phase 5: Testing & Documentation +1. Unit tests (>85% coverage) +2. Integration tests +3. API documentation + +--- + +## 9. References + +- [Caddy DNS Challenge Documentation](https://caddyserver.com/docs/automatic-https#dns-challenge) +- [Caddy JSON Structure](https://caddyserver.com/docs/json/) +- [ACME DNS-01 Challenge Spec](https://datatracker.ietf.org/doc/html/rfc8555#section-8.4) +- [Go crypto/cipher Package](https://pkg.go.dev/crypto/cipher) +- [Caddy DNS Provider Modules](https://github.com/caddy-dns) + +--- + +*Document created: January 1, 2026* +*Issue: #21 - DNS Challenge Support* +*Priority: Critical* diff --git a/docs/plans/dns_challenge_frontend_research.md b/docs/plans/dns_challenge_frontend_research.md new file mode 100644 index 00000000..efe58e9d --- /dev/null +++ b/docs/plans/dns_challenge_frontend_research.md @@ -0,0 +1,533 @@ +# DNS Challenge Frontend Research - Issue #21 + +## Overview + +This document outlines the frontend architecture analysis and implementation plan for DNS challenge support in the Charon proxy manager. DNS challenges are required for wildcard certificate issuance via Let's Encrypt/ACME. + +--- + +## 1. Existing Frontend Architecture Analysis + +### Technology Stack + +| Layer | Technology | +|-------|------------| +| Framework | React 18+ with TypeScript | +| State Management | TanStack Query (React Query) | +| Routing | React Router | +| UI Components | Custom component library (Radix UI primitives) | +| Styling | Tailwind CSS with custom design tokens | +| Internationalization | react-i18next | +| HTTP Client | Axios | +| Icons | Lucide React | + +### Directory Structure + +``` +frontend/src/ +├── api/ # API client functions (typed, async) +├── components/ # Reusable UI components +│ ├── ui/ # Design system primitives +│ ├── layout/ # Layout components (PageShell, etc.) +│ └── dialogs/ # Modal dialogs +├── hooks/ # Custom React hooks (data fetching) +├── pages/ # Page-level components +└── utils/ # Utility functions +``` + +### Key Architectural Patterns + +1. **API Layer** (`frontend/src/api/`) + - Each domain has its own API file (e.g., `certificates.ts`, `smtp.ts`) + - Uses typed interfaces for request/response + - All functions are async, returning Promise types + - Axios client with base URL `/api/v1` + +2. **Custom Hooks** (`frontend/src/hooks/`) + - Wrap TanStack Query for data fetching + - Naming convention: `use{Resource}` (e.g., `useCertificates`, `useDomains`) + - Return `{ data, isLoading, error, refetch }` pattern + +3. **Page Components** (`frontend/src/pages/`) + - Use `PageShell` wrapper for consistent layout + - Header with title, description, and action buttons + - Card-based content organization + +4. **Form Patterns** + - Use controlled components with `useState` + - `useMutation` for form submissions + - Toast notifications for success/error feedback + - Inline validation with error state + +--- + +## 2. UI Component Patterns Identified + +### Design System Components (`frontend/src/components/ui/`) + +| Component | Purpose | Usage | +|-----------|---------|-------| +| `Button` | Primary actions, variants: primary, secondary, danger, ghost | All forms | +| `Input` | Text/password inputs with label, error, helper text support | Forms | +| `Select` | Radix-based dropdown with proper styling | Provider selection | +| `Card` | Content containers with header/content/footer | Page sections | +| `Dialog` | Modal dialogs for forms/confirmations | Add/edit modals | +| `Alert` | Info/warning/error banners | Notifications | +| `Badge` | Status indicators | Provider status | +| `Label` | Form field labels | All form fields | +| `Textarea` | Multi-line text input | Advanced config | +| `Switch` | Toggle switches | Enable/disable | + +### Form Patterns (from `SMTPSettings.tsx`, `ProxyHostForm.tsx`) + +```tsx +// Standard form structure +const [formData, setFormData] = useState({ /* initial state */ }) + +const mutation = useMutation({ + mutationFn: async () => { /* API call */ }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resource'] }) + toast.success(t('resource.success')) + }, + onError: (error) => { + toast.error(error.message) + }, +}) + +// Form submission +const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + mutation.mutate() +} +``` + +### Password/Credential Input Pattern + +From `Input.tsx`: +- Built-in password visibility toggle +- Uses `type="password"` with eye icon +- Supports `helperText` for guidance +- `autoComplete` attributes for security + +### Test Connection Pattern (from `RemoteServerForm.tsx`, `SMTPSettings.tsx`) + +```tsx +const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle') + +const handleTestConnection = async () => { + setTestStatus('testing') + try { + await testConnection(/* params */) + setTestStatus('success') + setTimeout(() => setTestStatus('idle'), 3000) + } catch { + setTestStatus('error') + setTimeout(() => setTestStatus('idle'), 3000) + } +} +``` + +--- + +## 3. Proposed New Components + +### 3.1 DNS Providers Page (`DNSProviders.tsx`) + +**Location**: `frontend/src/pages/DNSProviders.tsx` + +**Purpose**: Manage DNS provider configurations for DNS-01 challenges + +**Layout**: +``` +┌─────────────────────────────────────────────────────────┐ +│ DNS Providers [+ Add Provider] │ +│ Configure DNS providers for wildcard certificates │ +├─────────────────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Alert: DNS providers enable wildcard certificates │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ Cloudflare │ │ Route53 │ │ +│ │ ✓ Configured │ │ Not configured │ │ +│ │ [Edit] [Test] [Del] │ │ [Configure] │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +**Features**: +- List configured DNS providers +- Add/Edit/Delete provider configurations +- Test DNS propagation +- Status badges (configured, error, pending) + +### 3.2 DNS Provider Form (`DNSProviderForm.tsx`) + +**Location**: `frontend/src/components/DNSProviderForm.tsx` + +**Purpose**: Add/edit DNS provider configuration + +**Key Features**: +- Dynamic form fields based on provider type +- Credential inputs with password masking +- Test connection button +- Validation feedback + +**Provider-Specific Fields**: + +| Provider | Required Fields | +|----------|-----------------| +| Cloudflare | API Token OR (API Key + Email) | +| Route53 | Access Key ID, Secret Access Key, Region | +| DigitalOcean | API Token | +| Google Cloud DNS | Service Account JSON | +| Namecheap | API User, API Key | +| GoDaddy | API Key, API Secret | +| Azure DNS | Client ID, Client Secret, Subscription ID, Resource Group | + +### 3.3 Provider Selector Dropdown (`DNSProviderSelector.tsx`) + +**Location**: `frontend/src/components/DNSProviderSelector.tsx` + +**Purpose**: Dropdown to select DNS provider when requesting wildcard certificates + +**Usage**: Integrated into `ProxyHostForm.tsx` or certificate request flow + +```tsx + +``` + +### 3.4 DNS Propagation Test Component (`DNSPropagationTest.tsx`) + +**Location**: `frontend/src/components/DNSPropagationTest.tsx` + +**Purpose**: Visual feedback for DNS TXT record propagation + +**Features**: +- Shows test domain and expected TXT record +- Real-time propagation status +- Retry button +- Success/failure indicators + +--- + +## 4. API Hooks Needed + +### 4.1 `useDNSProviders` Hook + +**Location**: `frontend/src/hooks/useDNSProviders.ts` + +```typescript +export function useDNSProviders() { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['dns-providers'], + queryFn: getDNSProviders, + }) + + return { + providers: data || [], + isLoading, + error, + refetch, + } +} +``` + +### 4.2 `useDNSProviderMutations` Hook + +**Location**: `frontend/src/hooks/useDNSProviders.ts` + +```typescript +export function useDNSProviderMutations() { + const queryClient = useQueryClient() + + const createMutation = useMutation({ + mutationFn: createDNSProvider, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dns-providers'] }), + }) + + const updateMutation = useMutation({ + mutationFn: updateDNSProvider, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dns-providers'] }), + }) + + const deleteMutation = useMutation({ + mutationFn: deleteDNSProvider, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dns-providers'] }), + }) + + const testMutation = useMutation({ + mutationFn: testDNSProvider, + }) + + return { createMutation, updateMutation, deleteMutation, testMutation } +} +``` + +--- + +## 5. API Client Functions Needed + +### 5.1 `frontend/src/api/dnsProviders.ts` + +```typescript +import client from './client' + +/** Supported DNS provider types */ +export type DNSProviderType = + | 'cloudflare' + | 'route53' + | 'digitalocean' + | 'gcloud' + | 'namecheap' + | 'godaddy' + | 'azure' + +/** DNS Provider configuration */ +export interface DNSProvider { + id: number + name: string + provider_type: DNSProviderType + is_default: boolean + created_at: string + updated_at: string + last_used_at?: string + status: 'active' | 'error' | 'unconfigured' +} + +/** Provider-specific credentials (varies by type) */ +export interface DNSProviderCredentials { + // Cloudflare + api_token?: string + api_key?: string + email?: string + // Route53 + access_key_id?: string + secret_access_key?: string + region?: string + // DigitalOcean + token?: string + // GCloud + service_account_json?: string + project_id?: string + // Generic + [key: string]: string | undefined +} + +/** Request payload for creating/updating provider */ +export interface DNSProviderRequest { + name: string + provider_type: DNSProviderType + credentials: DNSProviderCredentials + is_default?: boolean +} + +/** Test result response */ +export interface DNSTestResult { + success: boolean + message?: string + error?: string + propagation_time_ms?: number +} + +// API functions +export const getDNSProviders = async (): Promise => { + const response = await client.get('/dns-providers') + return response.data +} + +export const getDNSProvider = async (id: number): Promise => { + const response = await client.get(`/dns-providers/${id}`) + return response.data +} + +export const createDNSProvider = async (data: DNSProviderRequest): Promise => { + const response = await client.post('/dns-providers', data) + return response.data +} + +export const updateDNSProvider = async ( + id: number, + data: Partial +): Promise => { + const response = await client.put(`/dns-providers/${id}`, data) + return response.data +} + +export const deleteDNSProvider = async (id: number): Promise => { + await client.delete(`/dns-providers/${id}`) +} + +export const testDNSProvider = async (id: number): Promise => { + const response = await client.post(`/dns-providers/${id}/test`) + return response.data +} + +export const testDNSProviderCredentials = async ( + data: DNSProviderRequest +): Promise => { + const response = await client.post('/dns-providers/test', data) + return response.data +} +``` + +--- + +## 6. Files to Create/Modify + +### New Files to Create + +| File | Purpose | +|------|---------| +| `frontend/src/pages/DNSProviders.tsx` | DNS providers management page | +| `frontend/src/components/DNSProviderForm.tsx` | Add/edit provider form | +| `frontend/src/components/DNSProviderSelector.tsx` | Provider dropdown selector | +| `frontend/src/components/DNSPropagationTest.tsx` | Propagation test UI | +| `frontend/src/api/dnsProviders.ts` | API client functions | +| `frontend/src/hooks/useDNSProviders.ts` | Data fetching hooks | +| `frontend/src/data/dnsProviderSchemas.ts` | Provider field definitions | + +### Existing Files to Modify + +| File | Modification | +|------|--------------| +| `frontend/src/App.tsx` | Add route for `/dns-providers` | +| `frontend/src/components/layout/Layout.tsx` | Add navigation link | +| `frontend/src/pages/Certificates.tsx` | Add DNS provider selector for wildcard requests | +| `frontend/src/components/ProxyHostForm.tsx` | Add DNS provider option when wildcard domain detected | +| `frontend/src/api/certificates.ts` | Add `dns_provider_id` to certificate request types | + +--- + +## 7. Translation Keys Needed + +Add to `frontend/src/locales/en/translation.json`: + +```json +{ + "dnsProviders": { + "title": "DNS Providers", + "description": "Configure DNS providers for wildcard certificate issuance", + "addProvider": "Add Provider", + "editProvider": "Edit Provider", + "deleteProvider": "Delete Provider", + "deleteConfirm": "Are you sure you want to delete this DNS provider?", + "testConnection": "Test Connection", + "testSuccess": "DNS provider connection successful", + "testFailed": "DNS provider test failed", + "providerType": "Provider Type", + "providerName": "Provider Name", + "credentials": "Credentials", + "setAsDefault": "Set as Default", + "default": "Default", + "status": { + "active": "Active", + "error": "Error", + "unconfigured": "Not Configured" + }, + "providers": { + "cloudflare": "Cloudflare", + "route53": "Amazon Route 53", + "digitalocean": "DigitalOcean", + "gcloud": "Google Cloud DNS", + "namecheap": "Namecheap", + "godaddy": "GoDaddy", + "azure": "Azure DNS" + }, + "fields": { + "apiToken": "API Token", + "apiKey": "API Key", + "email": "Email", + "accessKeyId": "Access Key ID", + "secretAccessKey": "Secret Access Key", + "region": "Region", + "serviceAccountJson": "Service Account JSON", + "projectId": "Project ID" + }, + "hints": { + "cloudflare": "Use an API token with Zone:DNS:Edit permissions", + "route53": "IAM user with route53:ChangeResourceRecordSets permission", + "digitalocean": "Personal access token with write scope" + }, + "propagation": { + "title": "DNS Propagation", + "checking": "Checking DNS propagation...", + "success": "DNS record propagated successfully", + "failed": "DNS propagation check failed", + "retry": "Retry Check" + } + } +} +``` + +--- + +## 8. UI/UX Considerations + +### Provider Selection Flow + +1. User creates proxy host with wildcard domain (e.g., `*.example.com`) +2. System detects wildcard and shows DNS provider requirement +3. User selects existing provider OR creates new one inline +4. On save, backend uses DNS challenge for certificate + +### Security Considerations + +- Credentials masked by default (password inputs) +- API tokens never returned in full from backend (masked) +- Clear warning about credential storage +- Option to test without saving + +### Error Handling + +- Clear error messages for invalid credentials +- Specific guidance for each provider's setup +- Link to provider documentation +- Retry mechanisms for transient failures + +--- + +## 9. Implementation Priority + +1. **Phase 1**: API layer and hooks + - `dnsProviders.ts` API client + - `useDNSProviders.ts` hooks + +2. **Phase 2**: Core UI components + - `DNSProviders.tsx` page + - `DNSProviderForm.tsx` form + +3. **Phase 3**: Integration + - Navigation and routing + - Certificate/ProxyHost form integration + - Provider selector component + +4. **Phase 4**: Polish + - Translations + - Error handling refinement + - Propagation test UI + +--- + +## 10. Related Backend Requirements + +The frontend implementation depends on these backend API endpoints: + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| GET | `/api/v1/dns-providers` | List all providers | +| POST | `/api/v1/dns-providers` | Create 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 saved provider | +| POST | `/api/v1/dns-providers/test` | Test credentials before saving | + +--- + +*Research completed: 2026-01-01* diff --git a/docs/troubleshooting/dns-challenges.md b/docs/troubleshooting/dns-challenges.md new file mode 100644 index 00000000..8e27e4d5 --- /dev/null +++ b/docs/troubleshooting/dns-challenges.md @@ -0,0 +1,432 @@ +# DNS Challenge Troubleshooting + +This guide covers common issues with DNS-01 ACME challenges and how to resolve them. + +## Table of Contents + +- [Connection Test Failures](#connection-test-failures) +- [Certificate Issuance Failures](#certificate-issuance-failures) +- [DNS Propagation Issues](#dns-propagation-issues) +- [Provider-Specific Errors](#provider-specific-errors) +- [Network and Firewall Issues](#network-and-firewall-issues) +- [Credential Problems](#credential-problems) +- [Debugging Tips](#debugging-tips) + +## Connection Test Failures + +### Invalid Credentials + +**Symptoms:** +- "Invalid API token" or "Unauthorized" error +- Connection test fails immediately + +**Solutions:** +1. Verify credentials were copied correctly (no extra spaces/newlines) +2. Check token/key hasn't expired +3. Ensure token has required permissions: + - Cloudflare: Zone → DNS → Edit + - AWS: `route53:ChangeResourceRecordSets` + - DigitalOcean: Write scope +4. Regenerate credentials if necessary +5. Update configuration in Charon with new credentials + +### DNS Provider Unreachable + +**Symptoms:** +- "Connection timeout" or "Network error" +- Test hangs for 30+ seconds + +**Solutions:** +1. Check internet connectivity from Charon server +2. Verify firewall allows outbound HTTPS (port 443) +3. Test DNS resolution: + ```bash + # Test DNS provider API endpoint resolution + nslookup api.cloudflare.com + curl -I https://api.cloudflare.com + ``` +4. Check provider status page for service outages +5. Verify proxy settings if using HTTP proxy + +### Zone/Domain Not Found + +**Symptoms:** +- "Hosted zone not found" +- "Domain not configured" + +**Solutions:** +1. Verify domain is added to DNS provider account +2. Ensure domain status is Active (not Pending) +3. Check nameservers are correctly configured: + ```bash + dig NS example.com +short + ``` +4. Wait 24-48 hours if nameservers were recently changed +5. Verify API token is scoped to include the domain (if applicable) + +## Certificate Issuance Failures + +### DNS Propagation Timeout + +**Symptoms:** +- Certificate issuance fails after 2-5 minutes +- Error: "DNS propagation timeout" or "TXT record not found" + +**Solutions:** + +1. **Increase propagation timeout:** + - Navigate to DNS Provider settings + - Increase Propagation Timeout to 180-300 seconds + - Save and retry certificate issuance + +2. **Verify DNS propagation:** + ```bash + # Check if TXT record was created + dig _acme-challenge.example.com TXT +short + + # Check from multiple DNS servers + dig _acme-challenge.example.com TXT @8.8.8.8 +short + dig _acme-challenge.example.com TXT @1.1.1.1 +short + ``` + +3. **Check DNS provider configuration:** + - Ensure domain's nameservers point to your DNS provider + - Verify no conflicting DNS records exist + - Check DNSSEC is properly configured (if enabled) + +4. **Provider-specific adjustments:** + - **Cloudflare:** Usually fast (60s), check Cloudflare status + - **Route 53:** Often slow (120-180s), increase timeout + - **DigitalOcean:** Moderate (90s), verify nameservers + +### ACME Server Errors + +**Symptoms:** +- "Too many requests" or "Rate limit exceeded" +- "Invalid response from ACME server" + +**Solutions:** + +1. **Let's Encrypt rate limits:** + - 50 certificates per domain per week + - 5 failed validation attempts per hour + - Wait before retrying if limit hit + - Use staging environment for testing: + ```bash + # In Caddy config (for testing only) + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory + ``` + +2. **ACME challenge failures:** + - Review Charon logs for specific ACME error codes + - Verify TXT record was created correctly + - Ensure DNS provider has write permissions + - Test with a different DNS provider (if available) + +3. **Boulder (Let's Encrypt) validation errors:** + - Error indicates which authoritative DNS server was queried + - Verify all nameservers return the TXT record + - Check for split-horizon DNS issues + +### Wildcard Domain Issues + +**Symptoms:** +- Wildcard certificate issuance fails +- Error: "DNS challenge required for wildcard domains" + +**Solutions:** +1. Verify DNS provider is configured in Charon +2. Select DNS provider when creating proxy host +3. Ensure wildcard syntax is correct: `*.example.com` +4. Confirm DNS provider has permissions for the root domain +5. Test with non-wildcard domain first (e.g., `test.example.com`) + +## DNS Propagation Issues + +### Slow Global Propagation + +**Symptoms:** +- Certificate issuance succeeds locally but fails remotely +- Inconsistent results from different DNS resolvers + +**Diagnostic Commands:** +```bash +# Check propagation from multiple locations +dig _acme-challenge.example.com TXT @8.8.8.8 +dig _acme-challenge.example.com TXT @1.1.1.1 +dig _acme-challenge.example.com TXT @208.67.222.222 + +# Check TTL of existing records +dig example.com +noall +answer | grep -i ttl +``` + +**Solutions:** +1. Increase Propagation Timeout to 300-600 seconds +2. Lower TTL on existing DNS records (set 1 hour before changes) +3. Wait for previous high-TTL records to expire +4. Use DNS provider with faster global propagation (e.g., Cloudflare) + +### Cached DNS Records + +**Symptoms:** +- Old TXT records still visible after deletion +- Certificate renewal fails with "Incorrect TXT record" + +**Solutions:** +1. Wait for TTL expiry (default: 300-3600 seconds) +2. Flush local DNS cache: + ```bash + # Linux + sudo systemd-resolve --flush-caches + + # macOS + sudo dscacheutil -flushcache + ``` +3. Test with authoritative nameservers directly: + ```bash + dig _acme-challenge.example.com TXT @ns1.your-provider.com + ``` + +## Provider-Specific Errors + +### Cloudflare + +**Error:** `Cloudflare API error 6003: Invalid request headers` +- **Cause:** Malformed API token +- **Solution:** Regenerate token, ensure no invisible characters + +**Error:** `Cloudflare API error 10000: Authentication error` +- **Cause:** Token revoked or expired +- **Solution:** Create new token with correct permissions + +**Error:** `Zone is not active` +- **Cause:** Nameservers not updated +- **Solution:** Update domain nameservers, wait for activation + +### AWS Route 53 + +**Error:** `AccessDenied: User is not authorized` +- **Cause:** IAM permissions insufficient +- **Solution:** Attach IAM policy with `route53:ChangeResourceRecordSets` + +**Error:** `InvalidChangeBatch: RRSet with duplicate name` +- **Cause:** Conflicting TXT record already exists +- **Solution:** Remove manual `_acme-challenge` TXT records + +**Error:** `Throttling: Rate exceeded` +- **Cause:** Too many API requests +- **Solution:** Increase polling interval to 15-20 seconds + +### DigitalOcean + +**Error:** `The resource you requested could not be found` +- **Cause:** Domain not in DigitalOcean DNS +- **Solution:** Add domain to Networking → Domains + +**Error:** `Unable to authenticate you` +- **Cause:** Token has Read scope instead of Write +- **Solution:** Regenerate token with Write scope + +## Network and Firewall Issues + +### Outbound HTTPS Blocked + +**Symptoms:** +- Connection tests timeout +- "Network unreachable" errors + +**Diagnostic Commands:** +```bash +# Test connectivity to DNS provider API +curl -v https://api.cloudflare.com/client/v4/user +curl -v https://api.digitalocean.com/v2/account + +# Check if firewall is blocking +sudo iptables -L OUTPUT -v -n | grep -i drop +``` + +**Solutions:** +1. Allow outbound HTTPS (port 443) in firewall +2. Whitelist DNS provider API endpoints +3. Configure HTTP proxy if required: + ```bash + export HTTP_PROXY=http://proxy.example.com:8080 + export HTTPS_PROXY=http://proxy.example.com:8080 + ``` + +### DNS Resolution Failures + +**Symptoms:** +- Cannot resolve DNS provider API domains +- Error: "No such host" + +**Diagnostic Commands:** +```bash +# Test DNS resolution +nslookup api.cloudflare.com +dig api.cloudflare.com + +# Check /etc/resolv.conf +cat /etc/resolv.conf +``` + +**Solutions:** +1. Verify DNS server is configured correctly +2. Test with public DNS (8.8.8.8, 1.1.1.1) +3. Check network interface configuration +4. Restart networking service + +## Credential Problems + +### Encryption Key Issues + +**Symptoms:** +- "Encryption key not configured" +- "Failed to decrypt credentials" + +**Solutions:** + +1. **Set encryption key:** + ```bash + # Generate new key + openssl rand -base64 32 + + # Set environment variable + export CHARON_ENCRYPTION_KEY="your-base64-key-here" + ``` + +2. **Verify key in environment:** + ```bash + echo $CHARON_ENCRYPTION_KEY + # Should show 44-character base64 string + ``` + +3. **Docker/Docker Compose:** + ```yaml + # docker-compose.yml + services: + charon: + environment: + - CHARON_ENCRYPTION_KEY=${CHARON_ENCRYPTION_KEY} + ``` + +4. **Restart Charon after setting key** + +### Credentials Lost After Restart + +**Symptoms:** +- DNS provider shows "Unconfigured" status after restart +- Connection test fails with "Invalid credentials" + +**Cause:** Encryption key changed or missing + +**Solutions:** +1. Ensure `CHARON_ENCRYPTION_KEY` is persistent (not temporary) +2. Add to systemd service file, docker-compose, or .env file +3. Never change encryption key (all credentials will be unrecoverable) +4. If key is lost, reconfigure all DNS providers + +## Debugging Tips + +### Enable Debug Logging + +```bash +# Set log level in Charon configuration +export CHARON_LOG_LEVEL=debug + +# Restart Charon +``` + +### Review Charon Logs + +```bash +# Docker +docker logs charon -f --tail 100 + +# Systemd +journalctl -u charon -f -n 100 + +# Look for lines containing: +# - "DNS provider" +# - "ACME challenge" +# - "Certificate issuance" +``` + +### Test DNS Provider Manually + +Use Caddy directly to test DNS provider: + +```bash +# Create test Caddyfile +cat > Caddyfile << 'EOF' +test.example.com { + tls { + dns cloudflare {env.CLOUDFLARE_API_TOKEN} + } + respond "Test successful" +} +EOF + +# Run Caddy with test config +CLOUDFLARE_API_TOKEN=your-token caddy run --config Caddyfile +``` + +### Check ACME Challenge TXT Record + +Monitor DNS changes during certificate issuance: + +```bash +# Watch for TXT record creation +watch -n 5 'dig _acme-challenge.example.com TXT +short' + +# Check authoritative nameservers +dig _acme-challenge.example.com TXT @$(dig NS example.com +short | head -1) +``` + +### Common Log Messages + +**Success:** +``` +[INFO] DNS provider test successful +[INFO] ACME challenge completed +[INFO] Certificate issued successfully +``` + +**Errors:** +``` +[ERROR] Failed to create TXT record: +[ERROR] DNS propagation timeout after 120 seconds +[ERROR] ACME validation failed: +``` + +## Getting Help + +If you're still experiencing issues: + +1. **Review Documentation:** + - [DNS Providers Overview](../guides/dns-providers.md) + - Provider-specific setup guides + - [Security best practices](../security/best-practices.md) + +2. **Gather Information:** + - Charon version and log excerpt + - DNS provider type + - Error message (exact text) + - Network environment (Docker, VPS, etc.) + +3. **Check Known Issues:** + - [GitHub Issues](https://github.com/Wikid82/Charon/issues) + - Release notes and changelogs + +4. **Contact Support:** + - Open a GitHub issue with debug logs + - Join community Discord/forum + - Include relevant diagnostic output (sanitize credentials) + +## Related Documentation + +- [DNS Providers Guide](../guides/dns-providers.md) +- [Cloudflare Setup](../guides/dns-providers/cloudflare.md) +- [AWS Route 53 Setup](../guides/dns-providers/route53.md) +- [DigitalOcean Setup](../guides/dns-providers/digitalocean.md) +- [Certificate Management](../guides/certificates.md) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4fa6832d..58956882 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ const RemoteServers = lazy(() => import('./pages/RemoteServers')) const ImportCaddy = lazy(() => import('./pages/ImportCaddy')) const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec')) const Certificates = lazy(() => import('./pages/Certificates')) +const DNSProviders = lazy(() => import('./pages/DNSProviders')) const SystemSettings = lazy(() => import('./pages/SystemSettings')) const SMTPSettings = lazy(() => import('./pages/SMTPSettings')) const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig')) @@ -59,6 +60,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/dnsProviders.ts b/frontend/src/api/dnsProviders.ts new file mode 100644 index 00000000..dc3b514d --- /dev/null +++ b/frontend/src/api/dnsProviders.ts @@ -0,0 +1,163 @@ +import client from './client' + +/** Supported DNS provider types */ +export type DNSProviderType = + | 'cloudflare' + | 'route53' + | 'digitalocean' + | 'googleclouddns' + | 'namecheap' + | 'godaddy' + | 'azure' + | 'hetzner' + | 'vultr' + | 'dnsimple' + +/** Represents a configured DNS provider */ +export interface DNSProvider { + id: number + uuid: string + name: string + provider_type: DNSProviderType + enabled: boolean + is_default: boolean + has_credentials: boolean + propagation_timeout: number + polling_interval: number + last_used_at?: string + success_count: number + failure_count: number + last_error?: string + created_at: string + updated_at: string +} + +/** Request payload for creating/updating DNS providers */ +export interface DNSProviderRequest { + name: string + provider_type: DNSProviderType + credentials: Record + propagation_timeout?: number + polling_interval?: number + is_default?: boolean +} + +/** DNS provider test result */ +export interface DNSTestResult { + success: boolean + message?: string + error?: string + code?: string + propagation_time_ms?: number +} + +/** DNS provider type information with field definitions */ +export interface DNSProviderTypeInfo { + type: DNSProviderType + name: string + fields: Array<{ + name: string + label: string + type: 'text' | 'password' + required: boolean + default?: string + hint?: string + }> + documentation_url: string +} + +/** Response for list endpoint */ +interface ListDNSProvidersResponse { + providers: DNSProvider[] + total: number +} + +/** Response for types endpoint */ +interface DNSProviderTypesResponse { + types: DNSProviderTypeInfo[] +} + +/** + * Fetches all configured DNS providers. + * @returns Promise resolving to array of DNS providers + * @throws {AxiosError} If the request fails + */ +export async function getDNSProviders(): Promise { + const response = await client.get('/dns-providers') + return response.data.providers +} + +/** + * Fetches a single DNS provider by ID. + * @param id - The DNS provider ID + * @returns Promise resolving to the DNS provider + * @throws {AxiosError} If not found or request fails + */ +export async function getDNSProvider(id: number): Promise { + const response = await client.get(`/dns-providers/${id}`) + return response.data +} + +/** + * Creates a new DNS provider. + * @param data - DNS provider configuration + * @returns Promise resolving to the created provider + * @throws {AxiosError} If validation fails or request fails + */ +export async function createDNSProvider(data: DNSProviderRequest): Promise { + const response = await client.post('/dns-providers', data) + return response.data +} + +/** + * Updates an existing DNS provider. + * @param id - The DNS provider ID + * @param data - Updated configuration + * @returns Promise resolving to the updated provider + * @throws {AxiosError} If not found, validation fails, or request fails + */ +export async function updateDNSProvider(id: number, data: DNSProviderRequest): Promise { + const response = await client.put(`/dns-providers/${id}`, data) + return response.data +} + +/** + * Deletes a DNS provider. + * @param id - The DNS provider ID + * @throws {AxiosError} If not found or in use by proxy hosts + */ +export async function deleteDNSProvider(id: number): Promise { + await client.delete(`/dns-providers/${id}`) +} + +/** + * Tests connectivity of a saved DNS provider. + * @param id - The DNS provider ID + * @returns Promise resolving to test result + * @throws {AxiosError} If not found or request fails + */ +export async function testDNSProvider(id: number): Promise { + const response = await client.post(`/dns-providers/${id}/test`) + return response.data +} + +/** + * Tests DNS provider credentials before saving. + * @param data - Provider configuration to test + * @returns Promise resolving to test result + * @throws {AxiosError} If validation fails or request fails + */ +export async function testDNSProviderCredentials(data: DNSProviderRequest): Promise { + const response = await client.post('/dns-providers/test', data) + return response.data +} + +/** + * Fetches supported DNS provider types with field definitions. + * @returns Promise resolving to array of provider type info + * @throws {AxiosError} If request fails + */ +export async function getDNSProviderTypes(): Promise { + const response = await client.get('/dns-providers/types') + return response.data.types +} diff --git a/frontend/src/api/proxyHosts.ts b/frontend/src/api/proxyHosts.ts index 0e17a28b..70ea6e06 100644 --- a/frontend/src/api/proxyHosts.ts +++ b/frontend/src/api/proxyHosts.ts @@ -44,6 +44,7 @@ export interface ProxyHost { certificate?: Certificate | null; access_list_id?: number | null; security_header_profile_id?: number | null; + dns_provider_id?: number | null; security_header_profile?: { id: number; uuid: string; diff --git a/frontend/src/components/DNSProviderCard.tsx b/frontend/src/components/DNSProviderCard.tsx new file mode 100644 index 00000000..495c9103 --- /dev/null +++ b/frontend/src/components/DNSProviderCard.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Edit, + Trash2, + TestTube, + Star, + CheckCircle, + XCircle, + AlertTriangle, +} from 'lucide-react' +import { formatDistanceToNow } from 'date-fns' +import { + Card, + CardHeader, + CardTitle, + CardContent, + Button, + Badge, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from './ui' +import type { DNSProvider } from '../api/dnsProviders' + +interface DNSProviderCardProps { + provider: DNSProvider + onEdit: (provider: DNSProvider) => void + onDelete: (id: number) => void + onTest: (id: number) => void + isTesting?: boolean +} + +export default function DNSProviderCard({ + provider, + onEdit, + onDelete, + onTest, + isTesting = false, +}: DNSProviderCardProps) { + const { t } = useTranslation() + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + + const getStatusBadge = () => { + if (!provider.has_credentials) { + return ( + + + {t('dnsProviders.unconfigured')} + + ) + } + if (provider.last_error) { + return ( + + + {t('dnsProviders.error')} + + ) + } + if (provider.enabled) { + return ( + + + {t('dnsProviders.active')} + + ) + } + return ( + + {t('common.disabled')} + + ) + } + + const getProviderIcon = (type: string) => { + const iconMap: Record = { + cloudflare: '☁️', + route53: '🔶', + digitalocean: '🐙', + googleclouddns: '🔵', + namecheap: '🏢', + godaddy: '🟢', + azure: '⚡', + hetzner: '🟠', + vultr: '🔷', + dnsimple: '💎', + } + return iconMap[type] || '🌐' + } + + const handleDeleteConfirm = () => { + onDelete(provider.id) + setShowDeleteDialog(false) + } + + return ( + <> + + +
+
+
{getProviderIcon(provider.provider_type)}
+
+ + {provider.name} + {provider.is_default && ( + + )} + +

+ {t(`dnsProviders.types.${provider.provider_type}`, provider.provider_type)} +

+
+
+ {getStatusBadge()} +
+
+ + + {/* Usage Stats */} +
+
+

{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} +

+
+
+ + {/* Settings */} +
+
+

{t('dnsProviders.propagationTimeout')}

+

{provider.propagation_timeout}s

+
+
+

{t('dnsProviders.pollingInterval')}

+

{provider.polling_interval}s

+
+
+ + {/* Last Error */} + {provider.last_error && ( +
+

{t('dnsProviders.lastError')}

+

{provider.last_error}

+
+ )} + + {/* Action Buttons */} +
+ + + +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + {t('dnsProviders.deleteProvider')} + + {t('dnsProviders.deleteConfirmation', { name: provider.name })} + + + + + + + + + + ) +} diff --git a/frontend/src/components/DNSProviderForm.tsx b/frontend/src/components/DNSProviderForm.tsx new file mode 100644 index 00000000..5209d0ee --- /dev/null +++ b/frontend/src/components/DNSProviderForm.tsx @@ -0,0 +1,327 @@ +import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronDown, ChevronUp, ExternalLink, CheckCircle, XCircle } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Checkbox, + Alert, +} from './ui' +import { useDNSProviderTypes, useDNSProviderMutations, type DNSProvider } from '../hooks/useDNSProviders' +import type { DNSProviderRequest, DNSProviderTypeInfo } from '../api/dnsProviders' +import { defaultProviderSchemas } from '../data/dnsProviderSchemas' + +interface DNSProviderFormProps { + open: boolean + onOpenChange: (open: boolean) => void + provider?: DNSProvider | null + onSuccess: () => void +} + +export default function DNSProviderForm({ + open, + onOpenChange, + provider = null, + onSuccess, +}: DNSProviderFormProps) { + const { t } = useTranslation() + const { data: providerTypes, isLoading: typesLoading } = useDNSProviderTypes() + const { createMutation, updateMutation, testCredentialsMutation } = useDNSProviderMutations() + + const [name, setName] = useState('') + const [providerType, setProviderType] = useState('') + const [credentials, setCredentials] = useState>({}) + const [propagationTimeout, setPropagationTimeout] = useState(120) + const [pollingInterval, setPollingInterval] = useState(5) + const [isDefault, setIsDefault] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + + // Populate form when editing + useEffect(() => { + if (provider) { + setName(provider.name) + setProviderType(provider.provider_type) + setPropagationTimeout(provider.propagation_timeout) + setPollingInterval(provider.polling_interval) + setIsDefault(provider.is_default) + setCredentials({}) // Don't pre-fill credentials (they're encrypted) + } else { + resetForm() + } + }, [provider, open]) + + const resetForm = () => { + setName('') + setProviderType('') + setCredentials({}) + setPropagationTimeout(120) + setPollingInterval(5) + setIsDefault(false) + setShowAdvanced(false) + setTestResult(null) + } + + const getSelectedProviderInfo = (): DNSProviderTypeInfo | undefined => { + if (!providerType) return undefined + return ( + providerTypes?.find((pt) => pt.type === providerType) || + (defaultProviderSchemas[providerType as keyof typeof defaultProviderSchemas] as DNSProviderTypeInfo) + ) + } + + const handleCredentialChange = (fieldName: string, value: string) => { + setCredentials((prev) => ({ ...prev, [fieldName]: value })) + } + + const handleTestConnection = async () => { + const selectedProvider = getSelectedProviderInfo() + if (!selectedProvider) return + + const data: DNSProviderRequest = { + name: name || 'Test', + provider_type: providerType as any, + credentials, + propagation_timeout: propagationTimeout, + polling_interval: pollingInterval, + } + + try { + const result = await testCredentialsMutation.mutateAsync(data) + setTestResult({ + success: result.success, + message: result.message || result.error || t('dnsProviders.testSuccess'), + }) + } catch (error: any) { + setTestResult({ + success: false, + message: error.response?.data?.error || error.message || t('dnsProviders.testFailed'), + }) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setTestResult(null) + + const data: DNSProviderRequest = { + name, + provider_type: providerType as any, + credentials, + propagation_timeout: propagationTimeout, + polling_interval: pollingInterval, + is_default: isDefault, + } + + try { + if (provider) { + await updateMutation.mutateAsync({ id: provider.id, data }) + } else { + await createMutation.mutateAsync(data) + } + onSuccess() + onOpenChange(false) + resetForm() + } catch (error) { + console.error('Failed to save DNS provider:', error) + } + } + + const selectedProviderInfo = getSelectedProviderInfo() + const isSubmitting = createMutation.isPending || updateMutation.isPending + const isTesting = testCredentialsMutation.isPending + + return ( + + + + + {provider ? t('dnsProviders.editProvider') : t('dnsProviders.addProvider')} + + + +
+ {/* Provider Type */} +
+ + +
+ + {/* Provider Name */} + setName(e.target.value)} + placeholder={t('dnsProviders.providerNamePlaceholder')} + required + /> + + {/* Dynamic Credential Fields */} + {selectedProviderInfo && ( + <> +
+
+ + {selectedProviderInfo.documentation_url && ( + + {t('dnsProviders.viewDocs')} + + + )} +
+ + {selectedProviderInfo.fields?.map((field) => ( + handleCredentialChange(field.name, e.target.value)} + placeholder={field.default} + helperText={field.hint} + required={field.required && !provider} // Don't require when editing (preserve existing) + /> + ))} +
+ + {/* Test Connection */} +
+ + + {testResult && ( + +
+ {testResult.success ? ( + + ) : ( + + )} +
+

+ {testResult.success + ? t('dnsProviders.testSuccess') + : t('dnsProviders.testFailed')} +

+

{testResult.message}

+
+
+
+ )} +
+ + )} + + {/* Advanced Settings */} +
+ + + {showAdvanced && ( +
+ setPropagationTimeout(parseInt(e.target.value, 10))} + helperText={t('dnsProviders.propagationTimeoutHint')} + min={30} + max={600} + /> + setPollingInterval(parseInt(e.target.value, 10))} + helperText={t('dnsProviders.pollingIntervalHint')} + min={1} + max={60} + /> +
+ setIsDefault(checked as boolean)} + /> + +
+
+ )} +
+ + {/* Form Actions */} + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/DNSProviderSelector.tsx b/frontend/src/components/DNSProviderSelector.tsx new file mode 100644 index 00000000..b4d7acec --- /dev/null +++ b/frontend/src/components/DNSProviderSelector.tsx @@ -0,0 +1,105 @@ +import { useTranslation } from 'react-i18next' +import { Star } from 'lucide-react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Label, +} from './ui' +import { useDNSProviders } from '../hooks/useDNSProviders' + +interface DNSProviderSelectorProps { + value?: number + onChange: (providerId: number | undefined) => void + required?: boolean + disabled?: boolean + label?: string + helperText?: string + error?: string +} + +export default function DNSProviderSelector({ + value, + onChange, + required = false, + disabled = false, + label, + helperText, + error, +}: DNSProviderSelectorProps) { + const { t } = useTranslation() + const { data: providers = [], isLoading } = useDNSProviders() + + // Filter to only enabled providers with credentials + const availableProviders = providers.filter( + (p) => p.enabled && p.has_credentials + ) + + const handleValueChange = (value: string) => { + if (value === 'none') { + onChange(undefined) + } else { + onChange(parseInt(value, 10)) + } + } + + return ( +
+ {label && ( + + )} + + {error && ( +

+ {error} +

+ )} + {helperText && !error && ( +

{helperText}

+ )} +
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a14784a3..22d2680d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -63,6 +63,7 @@ export default function Layout({ children }: LayoutProps) { { name: t('navigation.remoteServers'), path: '/remote-servers', icon: '🖥️' }, { name: t('navigation.domains'), path: '/domains', icon: '🌍' }, { name: t('navigation.certificates'), path: '/certificates', icon: '🔒' }, + { name: t('navigation.dnsProviders'), path: '/dns-providers', icon: '☁️' }, { name: t('navigation.uptime'), path: '/uptime', icon: '📈' }, { name: t('navigation.security'), path: '/security', icon: '🛡️', children: [ { name: t('navigation.dashboard'), path: '/security', icon: '🛡️' }, diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index 7de97d2b..0799a221 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -14,6 +14,7 @@ import { SecurityScoreDisplay } from './SecurityScoreDisplay' import { parse } from 'tldts' import { Alert } from './ui/Alert' import { isLikelyDockerContainerIP, isPrivateOrDockerIP } from '../utils/validation' +import DNSProviderSelector from './DNSProviderSelector' // Application preset configurations const APPLICATION_PRESETS: { value: ApplicationPreset; label: string; description: string }[] = [ @@ -111,6 +112,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor certificate_id: host?.certificate_id, access_list_id: host?.access_list_id, security_header_profile_id: host?.security_header_profile_id, + dns_provider_id: host?.dns_provider_id || null, }) // Charon internal IP for config helpers (previously CPMP internal IP) @@ -300,8 +302,20 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor const [uptimeInterval, setUptimeInterval] = useState(60) const [uptimeMaxRetries, setUptimeMaxRetries] = useState(3) + // Wildcard domain detection for DNS-01 challenge requirement + const hasWildcardDomain = formData.domain_names + ?.split(',') + .some(d => d.trim().startsWith('*')) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() + + // Validate DNS provider for wildcard domains + if (hasWildcardDomain && !formData.dns_provider_id) { + toast.error('DNS provider is required for wildcard domains') + return + } + setLoading(true) try { const payload = { ...formData } @@ -642,6 +656,28 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor

+ {/* DNS Provider Selector for Wildcard Domains */} + {hasWildcardDomain && ( +
+ + +
+

Wildcard Certificate Required

+

+ Wildcard certificates (*.example.com) require DNS-01 challenge. + Select a DNS provider to automatically manage DNS records for certificate validation. +

+
+
+ + setFormData(prev => ({ ...prev, dns_provider_id: id ?? null }))} + required={true} + /> +
+ )} + {/* Access Control List */} > = { + cloudflare: { + type: 'cloudflare', + name: 'Cloudflare', + fields: [ + { + 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/', + }, + route53: { + type: 'route53', + name: 'Amazon Route 53', + fields: [ + { + 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/', + }, + digitalocean: { + type: 'digitalocean', + name: 'DigitalOcean', + fields: [ + { + name: 'auth_token', + label: 'Auth Token', + type: 'password', + required: true, + }, + ], + documentation_url: 'https://docs.digitalocean.com/reference/api/', + }, + googleclouddns: { + type: 'googleclouddns', + name: 'Google Cloud DNS', + fields: [ + { + name: 'service_account_json', + label: 'Service Account JSON', + type: 'password', + required: true, + hint: 'Paste the entire JSON file contents', + }, + { + name: 'project', + label: 'Project ID', + type: 'text', + required: true, + }, + ], + documentation_url: 'https://cloud.google.com/dns/docs', + }, + namecheap: { + type: 'namecheap', + name: 'Namecheap', + fields: [ + { + name: 'api_user', + label: 'API User', + type: 'text', + required: true, + }, + { + name: 'api_key', + label: 'API Key', + type: 'password', + required: true, + }, + { + name: 'client_ip', + label: 'Client IP', + type: 'text', + required: true, + hint: 'Your whitelisted IP address', + }, + ], + documentation_url: 'https://www.namecheap.com/support/api/', + }, + godaddy: { + type: 'godaddy', + name: 'GoDaddy', + fields: [ + { + 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/', + }, + azure: { + type: 'azure', + name: 'Azure DNS', + fields: [ + { + 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://learn.microsoft.com/en-us/azure/dns/', + }, + hetzner: { + type: 'hetzner', + name: 'Hetzner', + fields: [ + { + name: 'api_key', + label: 'API Key', + type: 'password', + required: true, + }, + ], + documentation_url: 'https://dns.hetzner.com/api-docs', + }, + vultr: { + type: 'vultr', + name: 'Vultr', + fields: [ + { + name: 'api_key', + label: 'API Key', + type: 'password', + required: true, + }, + ], + documentation_url: 'https://www.vultr.com/api/', + }, + dnsimple: { + type: 'dnsimple', + name: 'DNSimple', + fields: [ + { + 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/', + }, +} diff --git a/frontend/src/hooks/useDNSProviders.ts b/frontend/src/hooks/useDNSProviders.ts new file mode 100644 index 00000000..9b3077ac --- /dev/null +++ b/frontend/src/hooks/useDNSProviders.ts @@ -0,0 +1,115 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + getDNSProviders, + getDNSProvider, + getDNSProviderTypes, + createDNSProvider, + updateDNSProvider, + deleteDNSProvider, + testDNSProvider, + testDNSProviderCredentials, + type DNSProvider, + type DNSProviderRequest, + type DNSProviderTypeInfo, + type DNSTestResult, +} from '../api/dnsProviders' + +/** Query key factory for DNS providers */ +const queryKeys = { + all: ['dns-providers'] as const, + lists: () => [...queryKeys.all, 'list'] as const, + list: () => [...queryKeys.lists()] as const, + details: () => [...queryKeys.all, 'detail'] as const, + detail: (id: number) => [...queryKeys.details(), id] as const, + types: () => [...queryKeys.all, 'types'] as const, +} + +/** + * Hook for fetching all DNS providers. + * @returns Query result with providers array + */ +export function useDNSProviders() { + return useQuery({ + queryKey: queryKeys.list(), + queryFn: getDNSProviders, + }) +} + +/** + * Hook for fetching a single DNS provider. + * @param id - DNS provider ID + * @returns Query result with provider data + */ +export function useDNSProvider(id: number) { + return useQuery({ + queryKey: queryKeys.detail(id), + queryFn: () => getDNSProvider(id), + enabled: id > 0, + }) +} + +/** + * Hook for fetching supported DNS provider types. + * @returns Query result with provider types array + */ +export function useDNSProviderTypes() { + return useQuery({ + queryKey: queryKeys.types(), + queryFn: getDNSProviderTypes, + staleTime: 1000 * 60 * 60, // 1 hour - types rarely change + }) +} + +/** + * Hook providing DNS provider mutation operations. + * @returns Object with mutation functions for create, update, delete, and test + */ +export function useDNSProviderMutations() { + const queryClient = useQueryClient() + + const createMutation = useMutation({ + mutationFn: (data: DNSProviderRequest) => createDNSProvider(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.list() }) + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: DNSProviderRequest }) => + updateDNSProvider(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.list() }) + queryClient.invalidateQueries({ queryKey: queryKeys.detail(variables.id) }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: number) => deleteDNSProvider(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.list() }) + }, + }) + + const testMutation = useMutation({ + mutationFn: (id: number) => testDNSProvider(id), + }) + + const testCredentialsMutation = useMutation({ + mutationFn: (data: DNSProviderRequest) => testDNSProviderCredentials(data), + }) + + return { + createMutation, + updateMutation, + deleteMutation, + testMutation, + testCredentialsMutation, + } +} + +export type { + DNSProvider, + DNSProviderRequest, + DNSProviderTypeInfo, + DNSTestResult, +} diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index bda0d024..d46877f2 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -51,6 +51,7 @@ "remoteServers": "Remote Servers", "domains": "Domains", "certificates": "Certificates", + "dnsProviders": "DNS Providers", "security": "Security", "accessLists": "Access Lists", "crowdsec": "CrowdSec", @@ -1022,5 +1023,60 @@ "strict": "Strong security for web applications.\n✓ Best for: Web-only dashboards, admin panels.\n⚠ May break mobile apps and API clients.\nNot recommended for Radarr, Plex, or services with companion apps.", "paranoid": "Maximum security for high-risk applications.\n✓ Best for: Banking, healthcare, compliance-critical apps.\n⚠ WILL break mobile apps, API clients, and OAuth flows.\nOnly use if you understand and can customize every header." } + }, + "dnsProviders": { + "title": "DNS Providers", + "description": "Manage DNS providers for wildcard certificate validation", + "note": "DNS Provider Information", + "noteText": "DNS providers are required to issue wildcard certificates (e.g., *.example.com) via Let's Encrypt DNS-01 challenge. Configure at least one provider to enable wildcard domain support.", + "addProvider": "Add DNS Provider", + "addFirstProvider": "Add Your First DNS Provider", + "editProvider": "Edit DNS Provider", + "deleteProvider": "Delete DNS Provider", + "noProviders": "No DNS Providers Configured", + "noProvidersDescription": "Add a DNS provider to enable wildcard certificate issuance for your domains.", + "providerName": "Provider Name", + "providerNamePlaceholder": "e.g., Production Cloudflare", + "providerType": "Provider Type", + "selectProviderType": "Select a DNS provider...", + "selectProvider": "Select DNS provider...", + "noProvider": "None (HTTP-01 Challenge)", + "noProvidersAvailable": "No providers available", + "credentials": "Credentials", + "viewDocs": "View Documentation", + "testConnection": "Test Connection", + "advancedSettings": "Advanced Settings", + "propagationTimeout": "Propagation Timeout (seconds)", + "propagationTimeoutHint": "Maximum time to wait for DNS propagation (30-600s)", + "pollingInterval": "Polling Interval (seconds)", + "pollingIntervalHint": "How often to check for DNS record propagation (1-60s)", + "setAsDefault": "Set as default provider", + "active": "Active", + "error": "Error", + "unconfigured": "Unconfigured", + "default": "Default Provider", + "lastUsed": "Last Used", + "neverUsed": "Never used", + "successRate": "Success / Failures", + "lastError": "Last Error", + "createSuccess": "DNS provider created successfully", + "updateSuccess": "DNS provider updated successfully", + "deleteSuccess": "DNS provider deleted successfully", + "deleteFailed": "Failed to delete DNS provider", + "deleteConfirmation": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "testSuccess": "Connection test successful", + "testFailed": "Connection test failed", + "types": { + "cloudflare": "Cloudflare", + "route53": "Amazon Route 53", + "digitalocean": "DigitalOcean", + "googleclouddns": "Google Cloud DNS", + "namecheap": "Namecheap", + "godaddy": "GoDaddy", + "azure": "Azure DNS", + "hetzner": "Hetzner", + "vultr": "Vultr", + "dnsimple": "DNSimple" + } } } diff --git a/frontend/src/pages/DNSProviders.tsx b/frontend/src/pages/DNSProviders.tsx new file mode 100644 index 00000000..f9f8501a --- /dev/null +++ b/frontend/src/pages/DNSProviders.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Plus, Cloud } from 'lucide-react' +import { PageShell } from '../components/layout/PageShell' +import { Button, Alert, EmptyState, Skeleton } from '../components/ui' +import DNSProviderCard from '../components/DNSProviderCard' +import DNSProviderForm from '../components/DNSProviderForm' +import { useDNSProviders, useDNSProviderMutations, type DNSProvider } from '../hooks/useDNSProviders' +import { toast } from '../utils/toast' + +export default function DNSProviders() { + const { t } = useTranslation() + const { data: providers = [], isLoading, refetch } = useDNSProviders() + const { deleteMutation, testMutation } = useDNSProviderMutations() + + const [isFormOpen, setIsFormOpen] = useState(false) + const [editingProvider, setEditingProvider] = useState(null) + const [testingProviderId, setTestingProviderId] = useState(null) + + const handleAddProvider = () => { + setEditingProvider(null) + setIsFormOpen(true) + } + + const handleEditProvider = (provider: DNSProvider) => { + setEditingProvider(provider) + setIsFormOpen(true) + } + + const handleDeleteProvider = async (id: number) => { + try { + await deleteMutation.mutateAsync(id) + toast.success(t('dnsProviders.deleteSuccess')) + } catch (error: any) { + toast.error( + t('dnsProviders.deleteFailed') + + ': ' + + (error.response?.data?.error || error.message) + ) + } + } + + const handleTestProvider = async (id: number) => { + setTestingProviderId(id) + try { + const result = await testMutation.mutateAsync(id) + if (result.success) { + toast.success(result.message || t('dnsProviders.testSuccess')) + } else { + toast.error(result.error || t('dnsProviders.testFailed')) + } + } catch (error: any) { + toast.error( + t('dnsProviders.testFailed') + + ': ' + + (error.response?.data?.error || error.message) + ) + } finally { + setTestingProviderId(null) + } + } + + const handleFormSuccess = () => { + toast.success( + editingProvider ? t('dnsProviders.updateSuccess') : t('dnsProviders.createSuccess') + ) + refetch() + } + + // Header actions + const headerActions = ( + + ) + + return ( + + {/* Info Alert */} + + {t('dnsProviders.note')}: {t('dnsProviders.noteText')} + + + {/* Loading State */} + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ )} + + {/* Empty State */} + {!isLoading && providers.length === 0 && ( + } + title={t('dnsProviders.noProviders')} + description={t('dnsProviders.noProvidersDescription')} + action={{ + label: t('dnsProviders.addFirstProvider'), + onClick: handleAddProvider, + }} + /> + )} + + {/* Provider Cards Grid */} + {!isLoading && providers.length > 0 && ( +
+ {providers.map((provider) => ( + + ))} +
+ )} + + {/* Add/Edit Form Dialog */} + +
+ ) +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5c50365f..406788f5 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -16,16 +16,43 @@ export default defineConfig({ build: { outDir: 'dist', sourcemap: true, + // Increase chunk size warning limit to 600KB (reasonable for modern networks) + chunkSizeWarningLimit: 600, // Code splitting for better caching and parallel loading rollupOptions: { output: { - manualChunks: { - // React ecosystem - changes rarely - 'react-vendor': ['react', 'react-dom', 'react-router-dom'], + manualChunks: (id) => { + // React core - changes rarely + if (id.includes('node_modules/react') || + id.includes('node_modules/react-dom') || + id.includes('node_modules/react-router')) { + return 'react-vendor' + } + // TanStack Query - changes rarely - 'query': ['@tanstack/react-query'], + if (id.includes('node_modules/@tanstack/react-query')) { + return 'query' + } + + // Radix UI components - large and stable + if (id.includes('node_modules/@radix-ui')) { + return 'radix-ui' + } + + // Recharts for dashboard - large but used infrequently + if (id.includes('node_modules/recharts')) { + return 'recharts' + } + // Icons - large but cacheable - 'icons': ['lucide-react'], + if (id.includes('node_modules/lucide-react')) { + return 'icons' + } + + // All other node_modules into vendor chunk + if (id.includes('node_modules')) { + return 'vendor' + } } } }