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