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 appHosts []out.AppHost,
26 external map[string]string,
27 additionalHosts []string,
28 resolver out.CloudflareZoneResolver,
29) ([]CertificateTarget, error) {
30 hosts := routeHosts(appHosts, 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 app hosts and external keys,
43// sorted alphabetically. Trailing dots are stripped before canonicalization.
44func routeHosts(appHosts []out.AppHost, 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 _, h := range appHosts {
61 // tls=never interfaces stay plain HTTP: no certificate target.
62 if h.TLSMode == domain.AppTLSNever {
63 continue
64 }
65 addHost(h.Host)
66 }
67 for h := range external {
68 addHost(h)
69 }
70 for _, h := range additionalHosts {
71 addHost(h)
72 }
73
74 sort.Strings(hosts)
75 return hosts
76}
77
78// deriveHTTP01Targets creates one target per host for HTTP-01 challenge.
79func deriveHTTP01Targets(hosts []string) []CertificateTarget {
80 targets := make([]CertificateTarget, len(hosts))
81 for i, host := range hosts {
82 targets[i] = CertificateTarget{
83 ID: "http01-" + host,
84 Names: []string{host},
85 Challenge: domain.ACMEChallengeHTTP01,
86 }
87 }
88 return targets
89}
90
91// deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.
92func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.CloudflareZoneResolver) ([]CertificateTarget, error) {
93 if resolver == nil {
94 return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")
95 }
96 baseSeen := make(map[string]struct{})
97 var bases []string
98
99 for _, host := range hosts {
100 zone, err := resolver.FindZone(ctx, host)
101 if err != nil {
102 return nil, fmt.Errorf("find zone for %s: %w", host, err)
103 }
104 base, err := wildcardBase(host, zone.Name)
105 if err != nil {
106 return nil, fmt.Errorf("find wildcard base for %s: %w", host, err)
107 }
108 if _, exists := baseSeen[base]; !exists {
109 baseSeen[base] = struct{}{}
110 bases = append(bases, base)
111 }
112 }
113
114 sort.Strings(bases)
115
116 targets := make([]CertificateTarget, len(bases))
117 for i, base := range bases {
118 targets[i] = CertificateTarget{
119 ID: "dns01-" + base,
120 Names: []string{base, "*." + base},
121 Challenge: domain.ACMEChallengeCloudflareDNS01,
122 }
123 }
124 return targets, nil
125}
126
127// wildcardBase determines the wildcard base for a host within a zone.
128// This is used by deriveDNS01Targets to group hosts under a common
129// wildcard certificate (e.g. example.com + *.example.com). Hosts that
130// share the same wildcard base are combined into a single DNS-01 target.
131//
132// If host == zone, returns zone.
133// If host is a direct child of zone, returns zone.
134// If host is deeper (e.g. api.prod.example.com within example.com),
135// returns the parent label under the zone (prod.example.com).
136func wildcardBase(host, zone string) (string, error) {
137 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
138 zone = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zone)), ".")
139 if host == "" || zone == "" {
140 return "", fmt.Errorf("host and zone must be non-empty")
141 }
142 if host == zone {
143 return zone, nil
144 }
145 if !strings.HasSuffix(host, "."+zone) {
146 return "", fmt.Errorf("zone %q does not match host %q", zone, host)
147 }
148
149 // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.
150 trimmed := strings.TrimSuffix(host, "."+zone)
151
152 if !strings.Contains(trimmed, ".") {
153 // Direct child of the zone (e.g. app.example.com).
154 return zone, nil
155 }
156
157 // Deeper than direct child: extract the parent under the zone.
158 // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"
159 _, parent, _ := strings.Cut(trimmed, ".")
160 return parent + "." + zone, nil
161}