main bac21c6

targets.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close