main
go 646 lines 17.2 KB
Raw
1 package cloudflare
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10
11 "github.com/go-acme/lego/v4/challenge"
12 "github.com/go-acme/lego/v4/providers/dns/cloudflare"
13
14 "github.com/gosuda/portal-tunnel/v2/utils"
15 )
16
17 const (
18 apiBase = "https://api.cloudflare.com/client/v4"
19 )
20
21 type Provider struct {
22 token string
23
24 zones *utils.Snapshot[map[string]string]
25 }
26
27 type apiError struct {
28 Message string `json:"message"`
29 Code int `json:"code"`
30 }
31
32 type zone struct {
33 ID string `json:"id"`
34 Name string `json:"name"`
35 }
36
37 type dnsRecord struct {
38 ID string `json:"id"`
39 Type string `json:"type"`
40 Name string `json:"name"`
41 Content string `json:"content"`
42 Data *dnsRecordData `json:"data,omitempty"`
43 }
44
45 type dnsRecordData struct {
46 Priority int `json:"priority,omitempty"`
47 Target string `json:"target,omitempty"`
48 Value string `json:"value,omitempty"`
49 }
50
51 type zonesResult struct {
52 Errors []apiError `json:"errors"`
53 Result []zone `json:"result"`
54 Success bool `json:"success"`
55 }
56
57 type recordsResult struct {
58 Errors []apiError `json:"errors"`
59 Result []dnsRecord `json:"result"`
60 Success bool `json:"success"`
61 }
62
63 type recordResult struct {
64 Result dnsRecord `json:"result"`
65 Errors []apiError `json:"errors"`
66 Success bool `json:"success"`
67 }
68
69 type dnssecDetails struct {
70 DS string `json:"ds"`
71 Status string `json:"status"`
72 }
73
74 type dnssecResult struct {
75 Result dnssecDetails `json:"result"`
76 Errors []apiError `json:"errors"`
77 Success bool `json:"success"`
78 }
79
80 func New(token string) *Provider {
81 return &Provider{
82 token: strings.TrimSpace(token),
83 zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
84 }
85 }
86
87 func (p *Provider) Name() string {
88 return "cloudflare"
89 }
90
91 func (p *Provider) ChallengeProvider(context.Context) (challenge.Provider, error) {
92 if p == nil {
93 return nil, errors.New("cloudflare provider is nil")
94 }
95 if p.token == "" {
96 return nil, errors.New("cloudflare token is required")
97 }
98
99 cfg := cloudflare.NewDefaultConfig()
100 cfg.AuthToken = p.token
101
102 provider, err := cloudflare.NewDNSProviderConfig(cfg)
103 if err != nil {
104 return nil, fmt.Errorf("create cloudflare lego provider: %w", err)
105 }
106 return provider, nil
107 }
108
109 func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
110 if p == nil {
111 return errors.New("cloudflare provider is nil")
112 }
113 baseDomain = utils.NormalizeBaseDomain(baseDomain)
114 if baseDomain == "" {
115 return errors.New("base domain is required")
116 }
117 if p.token == "" {
118 return errors.New("cloudflare token is required")
119 }
120 if err := utils.ValidateIPv4(publicIPv4); err != nil {
121 return err
122 }
123 publicIPv4 = strings.TrimSpace(publicIPv4)
124
125 zoneID, err := p.findZoneID(ctx, baseDomain)
126 if err != nil {
127 return fmt.Errorf("find cloudflare zone: %w", err)
128 }
129
130 for _, name := range []string{baseDomain, "*." + baseDomain} {
131 if err := ensureDNSRecord(ctx, p.token, zoneID, name, "A", publicIPv4); err != nil {
132 return fmt.Errorf("ensure A record for %s: %w", name, err)
133 }
134 }
135 return nil
136 }
137
138 func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
139 if p == nil {
140 return errors.New("cloudflare provider is nil")
141 }
142 name = utils.NormalizeHostname(name)
143 if name == "" {
144 return errors.New("record name is required")
145 }
146 if p.token == "" {
147 return errors.New("cloudflare token is required")
148 }
149 if err := utils.ValidateIPv4(publicIPv4); err != nil {
150 return err
151 }
152 publicIPv4 = strings.TrimSpace(publicIPv4)
153
154 zoneID, err := p.findZoneID(ctx, name)
155 if err != nil {
156 return fmt.Errorf("find cloudflare zone: %w", err)
157 }
158 if err := ensureDNSRecord(ctx, p.token, zoneID, name, "A", publicIPv4); err != nil {
159 return fmt.Errorf("ensure A record for %s: %w", name, err)
160 }
161 return nil
162 }
163
164 func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
165 if p == nil {
166 return errors.New("cloudflare provider is nil")
167 }
168 name = utils.NormalizeHostname(name)
169 if name == "" {
170 return errors.New("record name is required")
171 }
172 if p.token == "" {
173 return errors.New("cloudflare token is required")
174 }
175
176 zoneID, err := p.findZoneID(ctx, name)
177 if err != nil {
178 return fmt.Errorf("find cloudflare zone: %w", err)
179 }
180
181 records, err := listDNSRecords(ctx, p.token, zoneID, name, "A")
182 if err != nil {
183 return err
184 }
185 for _, record := range records {
186 if !strings.EqualFold(record.Name, name) {
187 continue
188 }
189 if err := deleteDNSRecord(ctx, p.token, zoneID, record.ID); err != nil {
190 return fmt.Errorf("delete A record %s: %w", name, err)
191 }
192 }
193 return nil
194 }
195
196 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
197 if p == nil {
198 return errors.New("cloudflare provider is nil")
199 }
200 name = utils.NormalizeHostname(name)
201 if name == "" {
202 return errors.New("record name is required")
203 }
204 if p.token == "" {
205 return errors.New("cloudflare token is required")
206 }
207 value = strings.TrimSpace(value)
208 if value == "" {
209 return errors.New("txt record value is required")
210 }
211
212 zoneID, err := p.findZoneID(ctx, name)
213 if err != nil {
214 return fmt.Errorf("find cloudflare zone: %w", err)
215 }
216 if err := ensureTXTRecord(ctx, p.token, zoneID, name, value); err != nil {
217 return fmt.Errorf("ensure TXT record for %s: %w", name, err)
218 }
219 return nil
220 }
221
222 func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
223 if p == nil {
224 return errors.New("cloudflare provider is nil")
225 }
226 name = utils.NormalizeHostname(name)
227 if name == "" {
228 return errors.New("record name is required")
229 }
230 if p.token == "" {
231 return errors.New("cloudflare token is required")
232 }
233 matchPrefix = strings.TrimSpace(matchPrefix)
234 if matchPrefix == "" {
235 return errors.New("txt record match prefix is required")
236 }
237
238 zoneID, err := p.findZoneID(ctx, name)
239 if err != nil {
240 return fmt.Errorf("find cloudflare zone: %w", err)
241 }
242
243 records, err := listDNSRecords(ctx, p.token, zoneID, name, "TXT")
244 if err != nil {
245 return err
246 }
247 for _, record := range records {
248 if !strings.EqualFold(record.Name, name) || !strings.HasPrefix(strings.TrimSpace(record.Content), matchPrefix) {
249 continue
250 }
251 if err := deleteDNSRecord(ctx, p.token, zoneID, record.ID); err != nil {
252 return fmt.Errorf("delete TXT record %s: %w", name, err)
253 }
254 }
255 return nil
256 }
257
258 func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, priority uint16, target, svcParams, content string) error {
259 if p == nil {
260 return errors.New("cloudflare provider is nil")
261 }
262 name = utils.NormalizeHostname(name)
263 if name == "" {
264 return errors.New("record name is required")
265 }
266 if p.token == "" {
267 return errors.New("cloudflare token is required")
268 }
269 target = strings.TrimSpace(target)
270 if target == "" {
271 return errors.New("https record target is required")
272 }
273 svcParams = strings.TrimSpace(svcParams)
274 if svcParams == "" {
275 return errors.New("https record svc params are required")
276 }
277 content = strings.TrimSpace(content)
278 if content == "" {
279 return errors.New("https record content is required")
280 }
281
282 zoneID, err := p.findZoneID(ctx, name)
283 if err != nil {
284 return fmt.Errorf("find cloudflare zone: %w", err)
285 }
286 if err := ensureHTTPSRecord(ctx, p.token, zoneID, name, priority, target, svcParams, content); err != nil {
287 return fmt.Errorf("ensure HTTPS record for %s: %w", name, err)
288 }
289 return nil
290 }
291
292 func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
293 if p == nil {
294 return errors.New("cloudflare provider is nil")
295 }
296 name = utils.NormalizeHostname(name)
297 if name == "" {
298 return errors.New("record name is required")
299 }
300 if p.token == "" {
301 return errors.New("cloudflare token is required")
302 }
303
304 zoneID, err := p.findZoneID(ctx, name)
305 if err != nil {
306 return fmt.Errorf("find cloudflare zone: %w", err)
307 }
308
309 records, err := listDNSRecords(ctx, p.token, zoneID, name, "HTTPS")
310 if err != nil {
311 return err
312 }
313 for _, record := range records {
314 if !strings.EqualFold(record.Name, name) {
315 continue
316 }
317 if err := deleteDNSRecord(ctx, p.token, zoneID, record.ID); err != nil {
318 return fmt.Errorf("delete HTTPS record %s: %w", name, err)
319 }
320 }
321 return nil
322 }
323
324 func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error) {
325 if p == nil {
326 return "", "", "", errors.New("cloudflare provider is nil")
327 }
328 baseDomain = utils.NormalizeBaseDomain(baseDomain)
329 if baseDomain == "" {
330 return "", "", "", errors.New("base domain is required")
331 }
332 if p.token == "" {
333 return "", "", "", errors.New("cloudflare token is required")
334 }
335
336 zoneID, err := p.findZoneID(ctx, baseDomain)
337 if err != nil {
338 return "", "", "", fmt.Errorf("find cloudflare zone: %w", err)
339 }
340
341 details, err := getDNSSEC(ctx, p.token, zoneID)
342 if err != nil {
343 return "", "", "", fmt.Errorf("get cloudflare dnssec status: %w", err)
344 }
345
346 switch strings.ToLower(strings.TrimSpace(details.Status)) {
347 case "active", "pending":
348 default:
349 if err := enableDNSSEC(ctx, p.token, zoneID); err != nil {
350 return "", "", "", fmt.Errorf("enable cloudflare dnssec: %w", err)
351 }
352 details, err = getDNSSEC(ctx, p.token, zoneID)
353 if err != nil {
354 return "", "", "", fmt.Errorf("refresh cloudflare dnssec status: %w", err)
355 }
356 }
357
358 state = strings.TrimSpace(details.Status)
359 dsRecord = strings.TrimSpace(details.DS)
360 if dsRecord != "" {
361 message = "publish the DS record at the registrar if Cloudflare Registrar does not manage the zone"
362 }
363 return state, dsRecord, message, nil
364 }
365
366 func (p *Provider) findZoneID(ctx context.Context, domain string) (string, error) {
367 domain = utils.NormalizeHostname(domain)
368 candidates := utils.DomainCandidates(domain)
369
370 zones := p.zones.Load()
371 for _, candidate := range candidates {
372 if zoneID := zones[candidate]; zoneID != "" {
373 return zoneID, nil
374 }
375 }
376
377 for _, candidate := range candidates {
378 zones, err := listZones(ctx, p.token, candidate)
379 if err != nil {
380 return "", err
381 }
382 for _, z := range zones {
383 if strings.EqualFold(z.Name, candidate) {
384 zoneID := strings.TrimSpace(z.ID)
385 if zoneID == "" {
386 continue
387 }
388 zoneName := utils.NormalizeHostname(z.Name)
389 p.zones.UpdateCopy(func(zones *map[string]string) {
390 if *zones == nil {
391 *zones = make(map[string]string)
392 }
393 (*zones)[zoneName] = zoneID
394 })
395 return zoneID, nil
396 }
397 }
398 }
399 return "", fmt.Errorf("no cloudflare zone found for %s", domain)
400 }
401
402 func ensureDNSRecord(ctx context.Context, token, zoneID, name, recordType, content string) error {
403 records, err := listDNSRecords(ctx, token, zoneID, name, recordType)
404 if err != nil {
405 return err
406 }
407
408 for _, record := range records {
409 if !strings.EqualFold(record.Name, name) {
410 continue
411 }
412 if record.Content == content {
413 return nil
414 }
415 return updateDNSRecord(ctx, token, zoneID, record.ID, recordType, name, content)
416 }
417
418 return createDNSRecord(ctx, token, zoneID, recordType, name, content)
419 }
420
421 func ensureTXTRecord(ctx context.Context, token, zoneID, name, value string) error {
422 records, err := listDNSRecords(ctx, token, zoneID, name, "TXT")
423 if err != nil {
424 return err
425 }
426 for _, record := range records {
427 if !strings.EqualFold(record.Name, name) {
428 continue
429 }
430 if strings.TrimSpace(record.Content) == value {
431 return nil
432 }
433 }
434 return createDNSRecord(ctx, token, zoneID, "TXT", name, value)
435 }
436
437 func ensureHTTPSRecord(ctx context.Context, token, zoneID, name string, priority uint16, target, svcParams, content string) error {
438 records, err := listDNSRecords(ctx, token, zoneID, name, "HTTPS")
439 if err != nil {
440 return err
441 }
442
443 for _, existing := range records {
444 if !strings.EqualFold(existing.Name, name) {
445 continue
446 }
447 if sameHTTPSRecord(existing, priority, target, svcParams, content) {
448 return nil
449 }
450 return updateHTTPSRecord(ctx, token, zoneID, existing.ID, name, priority, target, svcParams, content)
451 }
452
453 return createHTTPSRecord(ctx, token, zoneID, name, priority, target, svcParams, content)
454 }
455
456 func sameHTTPSRecord(existing dnsRecord, priority uint16, target, svcParams, content string) bool {
457 if existing.Data != nil {
458 existingTarget := strings.TrimSpace(existing.Data.Target)
459 if existingTarget == "" {
460 existingTarget = "."
461 }
462 return existing.Data.Priority == int(priority) &&
463 existingTarget == target &&
464 strings.TrimSpace(existing.Data.Value) == svcParams
465 }
466 return strings.TrimSpace(existing.Content) == content
467 }
468
469 func listZones(ctx context.Context, token, name string) ([]zone, error) {
470 u, _ := url.Parse(apiBase + "/zones")
471 q := u.Query()
472 q.Set("name", name)
473 u.RawQuery = q.Encode()
474
475 var out zonesResult
476 if err := utils.HTTPDoJSON(ctx, nil, http.MethodGet, u.String(), nil, cloudflareHeaders(token), &out); err != nil {
477 return nil, err
478 }
479 if !out.Success {
480 return nil, wrapErrors(out.Errors)
481 }
482 return out.Result, nil
483 }
484
485 func listDNSRecords(ctx context.Context, token, zoneID, name, recordType string) ([]dnsRecord, error) {
486 u, _ := url.Parse(fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID))
487 q := u.Query()
488 q.Set("name", name)
489 q.Set("type", recordType)
490 u.RawQuery = q.Encode()
491
492 var out recordsResult
493 if err := utils.HTTPDoJSON(ctx, nil, http.MethodGet, u.String(), nil, cloudflareHeaders(token), &out); err != nil {
494 return nil, err
495 }
496 if !out.Success {
497 return nil, wrapErrors(out.Errors)
498 }
499 return out.Result, nil
500 }
501
502 func getDNSSEC(ctx context.Context, token, zoneID string) (dnssecDetails, error) {
503 endpoint := fmt.Sprintf("%s/zones/%s/dnssec", apiBase, zoneID)
504
505 var out dnssecResult
506 if err := utils.HTTPDoJSON(ctx, nil, http.MethodGet, endpoint, nil, cloudflareHeaders(token), &out); err != nil {
507 return dnssecDetails{}, err
508 }
509 if !out.Success {
510 return dnssecDetails{}, wrapErrors(out.Errors)
511 }
512 return out.Result, nil
513 }
514
515 func enableDNSSEC(ctx context.Context, token, zoneID string) error {
516 endpoint := fmt.Sprintf("%s/zones/%s/dnssec", apiBase, zoneID)
517 body := map[string]any{
518 "status": "active",
519 }
520
521 var out dnssecResult
522 if err := utils.HTTPDoJSON(ctx, nil, http.MethodPatch, endpoint, body, cloudflareHeaders(token), &out); err != nil {
523 return err
524 }
525 if !out.Success {
526 return wrapErrors(out.Errors)
527 }
528 return nil
529 }
530
531 func createDNSRecord(ctx context.Context, token, zoneID, recordType, name, content string) error {
532 endpoint := fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID)
533 body := map[string]any{
534 "type": recordType,
535 "name": name,
536 "content": content,
537 "ttl": 1,
538 }
539 if strings.EqualFold(recordType, "A") {
540 body["proxied"] = false
541 }
542
543 var out recordResult
544 if err := utils.HTTPDoJSON(ctx, nil, http.MethodPost, endpoint, body, cloudflareHeaders(token), &out); err != nil {
545 return err
546 }
547 if !out.Success {
548 return wrapErrors(out.Errors)
549 }
550 return nil
551 }
552
553 func createHTTPSRecord(ctx context.Context, token, zoneID, name string, priority uint16, target, svcParams, content string) error {
554 endpoint := fmt.Sprintf("%s/zones/%s/dns_records", apiBase, zoneID)
555 body := httpsRecordBody("HTTPS", name, priority, target, svcParams, content)
556
557 var out recordResult
558 if err := utils.HTTPDoJSON(ctx, nil, http.MethodPost, endpoint, body, cloudflareHeaders(token), &out); err != nil {
559 return err
560 }
561 if !out.Success {
562 return wrapErrors(out.Errors)
563 }
564 return nil
565 }
566
567 func updateDNSRecord(ctx context.Context, token, zoneID, recordID, recordType, name, content string) error {
568 endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
569 body := map[string]any{
570 "type": recordType,
571 "name": name,
572 "content": content,
573 "ttl": 1,
574 }
575 if strings.EqualFold(recordType, "A") {
576 body["proxied"] = false
577 }
578
579 var out recordResult
580 if err := utils.HTTPDoJSON(ctx, nil, http.MethodPut, endpoint, body, cloudflareHeaders(token), &out); err != nil {
581 return err
582 }
583 if !out.Success {
584 return wrapErrors(out.Errors)
585 }
586 return nil
587 }
588
589 func updateHTTPSRecord(ctx context.Context, token, zoneID, recordID, name string, priority uint16, target, svcParams, content string) error {
590 endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
591 body := httpsRecordBody("HTTPS", name, priority, target, svcParams, content)
592
593 var out recordResult
594 if err := utils.HTTPDoJSON(ctx, nil, http.MethodPut, endpoint, body, cloudflareHeaders(token), &out); err != nil {
595 return err
596 }
597 if !out.Success {
598 return wrapErrors(out.Errors)
599 }
600 return nil
601 }
602
603 func httpsRecordBody(recordType, name string, priority uint16, target, svcParams, content string) map[string]any {
604 return map[string]any{
605 "type": recordType,
606 "name": name,
607 "content": content,
608 "data": map[string]any{
609 "priority": int(priority),
610 "target": target,
611 "value": svcParams,
612 },
613 "ttl": 1,
614 }
615 }
616
617 func deleteDNSRecord(ctx context.Context, token, zoneID, recordID string) error {
618 endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", apiBase, zoneID, recordID)
619
620 var out recordResult
621 if err := utils.HTTPDoJSON(ctx, nil, http.MethodDelete, endpoint, nil, cloudflareHeaders(token), &out); err != nil {
622 return err
623 }
624 if !out.Success {
625 return wrapErrors(out.Errors)
626 }
627 return nil
628 }
629
630 func cloudflareHeaders(token string) http.Header {
631 return http.Header{
632 "Authorization": []string{"Bearer " + token},
633 "Content-Type": []string{"application/json"},
634 }
635 }
636
637 func wrapErrors(errs []apiError) error {
638 if len(errs) == 0 {
639 return errors.New("cloudflare api request failed")
640 }
641 messages := make([]string, 0, len(errs))
642 for _, apiErr := range errs {
643 messages = append(messages, fmt.Sprintf("[%d] %s", apiErr.Code, apiErr.Message))
644 }
645 return errors.New(strings.Join(messages, "; "))
646 }