1package acmelego
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16)
17
18// zoneAPIResponse is a minimal Cloudflare API response for zone listing.
19type zoneAPIResponse struct {
20 Success bool `json:"success"`
21 Errors []zoneAPIError `json:"errors"`
22 Result []zoneAPIResult `json:"result"`
23}
24
25type zoneAPIError struct {
26 Code int `json:"code"`
27 Message string `json:"message"`
28}
29
30type zoneAPIResult struct {
31 ID string `json:"id"`
32 Name string `json:"name"`
33 Status string `json:"status"`
34}
35
36const defaultCloudflareBaseURL = "https://api.cloudflare.com/client/v4"
37
38// CloudflareZoneResolver resolves a hostname to its Cloudflare DNS zone by
39// searching from the full hostname down to the TLD.
40type CloudflareZoneResolver struct {
41 token string
42 baseURL string
43 client *http.Client
44
45 mu sync.RWMutex
46 // cache uses a small fixed maximum size; eviction scans the map to keep the
47 // resolver simple because defaultCacheMaxSize is intentionally low.
48 cache map[string]zoneCacheEntry
49}
50
51type zoneCacheEntry struct {
52 zone out.CloudflareZone
53 err error
54 createdAt time.Time
55 ttl time.Duration
56}
57
58const (
59 defaultCacheTTL = 10 * time.Minute
60 defaultErrorCacheTTL = time.Minute
61 defaultCloudflareTimeout = 10 * time.Second
62 defaultCacheMaxSize = 100
63 defaultMaxResponseSize = 1 << 20 // 1 MB
64)
65
66// CloudflareZoneResolverOption configures a CloudflareZoneResolver.
67type CloudflareZoneResolverOption func(*CloudflareZoneResolver)
68
69// WithCloudflareBaseURL sets the base URL for the Cloudflare API.
70func WithCloudflareBaseURL(baseURL string) CloudflareZoneResolverOption {
71 return func(r *CloudflareZoneResolver) {
72 r.baseURL = strings.TrimRight(baseURL, "/")
73 }
74}
75
76// WithCloudflareHTTPClient sets the HTTP client for the resolver.
77func WithCloudflareHTTPClient(client *http.Client) CloudflareZoneResolverOption {
78 return func(r *CloudflareZoneResolver) {
79 if client != nil {
80 r.client = client
81 }
82 }
83}
84
85// NewCloudflareZoneResolver creates a new CloudflareZoneResolver.
86func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOption) *CloudflareZoneResolver {
87 r := &CloudflareZoneResolver{
88 token: token,
89 baseURL: defaultCloudflareBaseURL,
90 client: &http.Client{Timeout: defaultCloudflareTimeout},
91 cache: make(map[string]zoneCacheEntry),
92 }
93 for _, opt := range opts {
94 opt(r)
95 }
96 return r
97}
98
99// compile-time interface check
100var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil)
101
102// FindZone finds the most specific Cloudflare zone for the given domain.
103// It searches from the full hostname down to the TLD (e.g. for
104// api.prod.example.com it tries api.prod.example.com, prod.example.com,
105// example.com, com) and returns the first active zone found.
106func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string) (out.CloudflareZone, error) {
107 domainName = strings.TrimSuffix(strings.TrimSpace(domainName), ".")
108 domainName = strings.ToLower(domainName)
109 if domainName == "" {
110 return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain")
111 }
112
113 if cached, ok := r.cached(domainName); ok {
114 return cached.zone, cached.err
115 }
116
117 parts := strings.Split(domainName, ".")
118
119 // candidateCount is the total possible candidates (full domain down to TLD).
120 // For "api.prod.example.com" -> 4 candidates; for "example.com" -> 2.
121 candidateCount := len(parts)
122 candidates := make([]string, 0, candidateCount)
123
124 // Build candidates from full hostname down to the TLD.
125 // For parts ["api","prod","example","com"]:
126 // "api.prod.example.com", "prod.example.com", "example.com", "com"
127 for i := range parts {
128 candidate := strings.Join(parts[i:], ".")
129 candidates = append(candidates, candidate)
130 }
131
132 var candidateErrs []error
133 for _, candidate := range candidates {
134 if cached, ok := r.cached(candidate); ok {
135 if cached.err == nil {
136 r.storeCache(domainName, cached.zone, nil)
137 return cached.zone, nil
138 }
139 candidateErrs = append(candidateErrs, fmt.Errorf("%s: %w", candidate, cached.err))
140 continue
141 }
142 zone, err := r.findZoneByName(ctx, candidate)
143 r.storeCache(candidate, zone, err)
144 if err != nil {
145 candidateErrs = append(candidateErrs, fmt.Errorf("%s: %w", candidate, err))
146 continue
147 }
148 r.storeCache(domainName, zone, nil)
149 return zone, nil
150 }
151
152 err := fmt.Errorf("cloudflare zone resolver: no active zone found for %q: %w", domainName, errors.Join(candidateErrs...))
153 r.storeCache(domainName, out.CloudflareZone{}, err)
154 return out.CloudflareZone{}, err
155}
156
157func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) {
158 r.mu.RLock()
159 entry, ok := r.cache[name]
160 if !ok {
161 r.mu.RUnlock()
162 return entry, false
163 }
164 if time.Since(entry.createdAt) > entry.ttl {
165 r.mu.RUnlock()
166 r.mu.Lock()
167 if current, ok := r.cache[name]; ok && current.createdAt.Equal(entry.createdAt) {
168 delete(r.cache, name)
169 }
170 r.mu.Unlock()
171 return zoneCacheEntry{}, false
172 }
173 r.mu.RUnlock()
174 return entry, true
175}
176
177func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone, err error) {
178 r.mu.Lock()
179 defer r.mu.Unlock()
180 if len(r.cache) >= defaultCacheMaxSize {
181 // Evict oldest entry with an O(n) scan. The cache is deliberately small
182 // (defaultCacheMaxSize), so this stays simple without an LRU list.
183 var oldestKey string
184 var oldestTime time.Time
185 for k, v := range r.cache {
186 if oldestKey == "" || v.createdAt.Before(oldestTime) {
187 oldestKey = k
188 oldestTime = v.createdAt
189 }
190 }
191 delete(r.cache, oldestKey)
192 }
193 ttl := defaultCacheTTL
194 if err != nil {
195 ttl = defaultErrorCacheTTL
196 }
197 r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now(), ttl: ttl}
198}
199
200// findZoneByName queries the Cloudflare API for an active zone with the exact given name.
201func (r *CloudflareZoneResolver) findZoneByName(ctx context.Context, name string) (out.CloudflareZone, error) {
202 u, err := url.Parse(r.baseURL + "/zones")
203 if err != nil {
204 return out.CloudflareZone{}, fmt.Errorf("parse url: %w", err)
205 }
206
207 q := u.Query()
208 q.Set("name", name)
209 q.Set("status", "active")
210 u.RawQuery = q.Encode()
211
212 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
213 if err != nil {
214 return out.CloudflareZone{}, fmt.Errorf("create request: %w", err)
215 }
216 req.Header.Set("Authorization", "Bearer "+r.token)
217 req.Header.Set("Accept", "application/json")
218
219 resp, err := r.client.Do(req)
220 if err != nil {
221 return out.CloudflareZone{}, fmt.Errorf("request failed: %w", err)
222 }
223 defer resp.Body.Close()
224
225 lr := io.LimitReader(resp.Body, defaultMaxResponseSize+1)
226 body, err := io.ReadAll(lr)
227 if err != nil {
228 return out.CloudflareZone{}, fmt.Errorf("read response: %w", err)
229 }
230 if len(body) > defaultMaxResponseSize {
231 return out.CloudflareZone{}, fmt.Errorf("cloudflare api: response body too large (%d bytes)", len(body))
232 }
233
234 if resp.StatusCode != http.StatusOK {
235 return out.CloudflareZone{}, fmt.Errorf("cloudflare api: status %d", resp.StatusCode)
236 }
237
238 var apiResp zoneAPIResponse
239 if err := json.Unmarshal(body, &apiResp); err != nil {
240 return out.CloudflareZone{}, fmt.Errorf("parse response: %w", err)
241 }
242
243 if !apiResp.Success {
244 errMsg := "cloudflare api returned unsuccessfully"
245 if len(apiResp.Errors) > 0 {
246 errMsg = apiResp.Errors[0].Message
247 }
248 return out.CloudflareZone{}, fmt.Errorf("cloudflare api: %s", errMsg)
249 }
250
251 for _, z := range apiResp.Result {
252 if z.Name == name && z.Status == "active" {
253 return out.CloudflareZone{
254 ID: z.ID,
255 Name: z.Name,
256 }, nil
257 }
258 }
259
260 return out.CloudflareZone{}, fmt.Errorf("no active zone found for %q", name)
261}