1package publictls
2
3import (
4 "context"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/bnema/gordon/internal/boundaries/out"
10 "github.com/bnema/gordon/internal/domain"
11)
12
13// CertificateTarget represents a single certificate to obtain or renew.
14type CertificateTarget struct {
15 ID string
16 Names []string
17 Challenge domain.ACMEChallengeMode
18}
19
20// DeriveCertificateTargets derives certificate targets from routes and external
21// routes based on the given ACME challenge mode.
22func DeriveCertificateTargets(
23 ctx context.Context,
24 mode domain.ACMEChallengeMode,
25 routes []domain.Route,
26 external map[string]string,
27 additionalHosts []string,
28 resolver out.CloudflareZoneResolver,
29) ([]CertificateTarget, error) {
30 hosts := routeHosts(routes, external, additionalHosts)
31
32 switch mode {
33 case domain.ACMEChallengeHTTP01:
34 return deriveHTTP01Targets(hosts), nil
35 case domain.ACMEChallengeCloudflareDNS01:
36 return deriveDNS01Targets(ctx, hosts, resolver)
37 default:
38 return nil, fmt.Errorf("unsupported challenge mode: %s", mode)
39 }
40}
41
42// routeHosts collects all unique canonical hosts from routes and external keys,
43// sorted alphabetically. Trailing dots are stripped before canonicalization.
44func routeHosts(routes []domain.Route, external map[string]string, additionalHosts []string) []string {
45 seen := make(map[string]struct{})
46 var hosts []string
47
48 addHost := func(raw string) {
49 raw = strings.TrimSuffix(raw, ".")
50 canonical, ok := domain.CanonicalRouteDomain(raw)
51 if !ok {
52 return
53 }
54 if _, exists := seen[canonical]; !exists {
55 seen[canonical] = struct{}{}
56 hosts = append(hosts, canonical)
57 }
58 }
59
60 for _, r := range routes {
61 addHost(r.Domain)
62 }
63 for h := range external {
64 addHost(h)
65 }
66 for _, h := range additionalHosts {
67 addHost(h)
68 }
69
70 sort.Strings(hosts)
71 return hosts
72}
73
74// deriveHTTP01Targets creates one target per host for HTTP-01 challenge.
75func deriveHTTP01Targets(hosts []string) []CertificateTarget {
76 targets := make([]CertificateTarget, len(hosts))
77 for i, host := range hosts {
78 targets[i] = CertificateTarget{
79 ID: "http01-" + host,
80 Names: []string{host},
81 Challenge: domain.ACMEChallengeHTTP01,
82 }
83 }
84 return targets
85}
86
87// deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.
88func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.CloudflareZoneResolver) ([]CertificateTarget, error) {
89 if resolver == nil {
90 return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")
91 }
92 baseSeen := make(map[string]struct{})
93 var bases []string
94
95 for _, host := range hosts {
96 zone, err := resolver.FindZone(ctx, host)
97 if err != nil {
98 return nil, fmt.Errorf("find zone for %s: %w", host, err)
99 }
100 base, err := wildcardBase(host, zone.Name)
101 if err != nil {
102 return nil, fmt.Errorf("find wildcard base for %s: %w", host, err)
103 }
104 if _, exists := baseSeen[base]; !exists {
105 baseSeen[base] = struct{}{}
106 bases = append(bases, base)
107 }
108 }
109
110 sort.Strings(bases)
111
112 targets := make([]CertificateTarget, len(bases))
113 for i, base := range bases {
114 targets[i] = CertificateTarget{
115 ID: "dns01-" + base,
116 Names: []string{base, "*." + base},
117 Challenge: domain.ACMEChallengeCloudflareDNS01,
118 }
119 }
120 return targets, nil
121}
122
123// wildcardBase determines the wildcard base for a host within a zone.
124// This is used by deriveDNS01Targets to group hosts under a common
125// wildcard certificate (e.g. example.com + *.example.com). Hosts that
126// share the same wildcard base are combined into a single DNS-01 target.
127//
128// If host == zone, returns zone.
129// If host is a direct child of zone, returns zone.
130// If host is deeper (e.g. api.prod.example.com within example.com),
131// returns the parent label under the zone (prod.example.com).
132func wildcardBase(host, zone string) (string, error) {
133 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
134 zone = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zone)), ".")
135 if host == "" || zone == "" {
136 return "", fmt.Errorf("host and zone must be non-empty")
137 }
138 if host == zone {
139 return zone, nil
140 }
141 if !strings.HasSuffix(host, "."+zone) {
142 return "", fmt.Errorf("zone %q does not match host %q", zone, host)
143 }
144
145 // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.
146 trimmed := strings.TrimSuffix(host, "."+zone)
147
148 if !strings.Contains(trimmed, ".") {
149 // Direct child of the zone (e.g. app.example.com).
150 return zone, nil
151 }
152
153 // Deeper than direct child: extract the parent under the zone.
154 // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"
155 _, parent, _ := strings.Cut(trimmed, ".")
156 return parent + "." + zone, nil
157}