cloudflare.go RawHistoryFolder

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}

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