Files
alexei.dolgolyov c730cfaa45 feat: Cloudflare DNS management with automatic record sync
Add flexible DNS management to Docker Watcher. By default, wildcard DNS
is assumed (current behavior). When disabled, users can configure a
Cloudflare DNS provider with API token and zone selection. DNS A records
are automatically created/updated/deleted in sync with proxy consumers
(deployed instances and standalone proxies).

- Settings: wildcard_dns toggle, dns_provider, cloudflare credentials
- Cloudflare client: Provider interface with EnsureRecord/DeleteRecord/ListRecords
- DNS lifecycle hooks in deployer and proxy manager (best-effort)
- Settings UI: DNS config section with provider picker, zone selector, test button
- DNS Records page at /dns with filtering, sync status, reconciliation
- Records visible in both wildcard and managed modes
- Cleanup on provider change: removes old records when switching modes
2026-04-02 14:49:21 +03:00

368 lines
8.9 KiB
Go

package dns
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const cfBaseURL = "https://api.cloudflare.com/client/v4"
// Cloudflare implements the Provider interface using the Cloudflare API v4.
type Cloudflare struct {
token string
zoneID string
client *http.Client
}
// NewCloudflare creates a new Cloudflare DNS provider.
// token is required. zoneID can be empty for ListZones/TestConnection calls.
func NewCloudflare(token, zoneID string) (*Cloudflare, error) {
if token == "" {
return nil, fmt.Errorf("cloudflare API token is required")
}
return &Cloudflare{
token: token,
zoneID: zoneID,
client: &http.Client{Timeout: 30 * time.Second},
}, nil
}
// --- Provider interface ---
// EnsureRecord creates or updates an A record for the given FQDN.
func (c *Cloudflare) EnsureRecord(ctx context.Context, fqdn, ip string) (string, error) {
if c.zoneID == "" {
return "", fmt.Errorf("zone ID is required for DNS operations")
}
// Check if a record already exists.
existing, err := c.findRecord(ctx, fqdn)
if err != nil {
return "", fmt.Errorf("find existing record: %w", err)
}
if existing != nil {
// Record exists — update if IP differs.
if existing.Content == ip {
return existing.ID, nil // already correct, no-op
}
updated, err := c.updateRecord(ctx, existing.ID, fqdn, ip)
if err != nil {
return "", fmt.Errorf("update record: %w", err)
}
return updated.ID, nil
}
// Record doesn't exist — create it.
created, err := c.createRecord(ctx, fqdn, ip)
if err != nil {
return "", fmt.Errorf("create record: %w", err)
}
return created.ID, nil
}
// DeleteRecord removes an A record by FQDN. Returns nil if not found.
func (c *Cloudflare) DeleteRecord(ctx context.Context, fqdn string) error {
if c.zoneID == "" {
return fmt.Errorf("zone ID is required for DNS operations")
}
existing, err := c.findRecord(ctx, fqdn)
if err != nil {
return fmt.Errorf("find record: %w", err)
}
if existing == nil {
return nil // doesn't exist, nothing to delete
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, existing.ID)
if _, err := c.doRequest(ctx, http.MethodDelete, endpoint, nil); err != nil {
return fmt.Errorf("delete record: %w", err)
}
return nil
}
// ListRecords returns all A records in the zone.
func (c *Cloudflare) ListRecords(ctx context.Context) ([]Record, error) {
if c.zoneID == "" {
return nil, fmt.Errorf("zone ID is required for DNS operations")
}
var allRecords []Record
page := 1
for {
endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&page=%d&per_page=100", cfBaseURL, c.zoneID, page)
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
var resp cfListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode list response: %w", err)
}
for _, r := range resp.Result {
allRecords = append(allRecords, Record{
ID: r.ID,
FQDN: r.Name,
Type: r.Type,
Content: r.Content,
TTL: r.TTL,
Proxied: r.Proxied,
})
}
if page >= resp.ResultInfo.TotalPages {
break
}
page++
}
return allRecords, nil
}
// TestConnection verifies the API token is valid.
func (c *Cloudflare) TestConnection(ctx context.Context) error {
endpoint := cfBaseURL + "/user/tokens/verify"
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("verify token: %w", err)
}
var resp cfBaseResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode verify response: %w", err)
}
if !resp.Success {
return fmt.Errorf("token verification failed: %s", formatErrors(resp.Errors))
}
return nil
}
// --- Additional methods (not part of Provider interface) ---
// ListZones returns all zones accessible by the token.
func (c *Cloudflare) ListZones(ctx context.Context) ([]Zone, error) {
var allZones []Zone
page := 1
for {
endpoint := fmt.Sprintf("%s/zones?page=%d&per_page=50&status=active", cfBaseURL, page)
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("list zones: %w", err)
}
var resp cfZonesResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode zones response: %w", err)
}
for _, z := range resp.Result {
allZones = append(allZones, Zone{
ID: z.ID,
Name: z.Name,
})
}
if page >= resp.ResultInfo.TotalPages {
break
}
page++
}
return allZones, nil
}
// --- Internal helpers ---
func (c *Cloudflare) findRecord(ctx context.Context, fqdn string) (*cfDNSRecord, error) {
endpoint := fmt.Sprintf("%s/zones/%s/dns_records?type=A&name=%s",
cfBaseURL, c.zoneID, url.QueryEscape(fqdn))
body, err := c.doRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
var resp cfListResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode find response: %w", err)
}
if len(resp.Result) == 0 {
return nil, nil
}
return &resp.Result[0], nil
}
func (c *Cloudflare) createRecord(ctx context.Context, fqdn, ip string) (*cfDNSRecord, error) {
payload := cfDNSRecordRequest{
Type: "A",
Name: fqdn,
Content: ip,
TTL: 1, // auto
Proxied: false,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal create payload: %w", err)
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records", cfBaseURL, c.zoneID)
body, err := c.doRequest(ctx, http.MethodPost, endpoint, data)
if err != nil {
return nil, err
}
var resp cfSingleResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode create response: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("create failed: %s", formatErrors(resp.Errors))
}
return &resp.Result, nil
}
func (c *Cloudflare) updateRecord(ctx context.Context, recordID, fqdn, ip string) (*cfDNSRecord, error) {
payload := cfDNSRecordRequest{
Type: "A",
Name: fqdn,
Content: ip,
TTL: 1,
Proxied: false,
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal update payload: %w", err)
}
endpoint := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfBaseURL, c.zoneID, recordID)
body, err := c.doRequest(ctx, http.MethodPut, endpoint, data)
if err != nil {
return nil, err
}
var resp cfSingleResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("decode update response: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("update failed: %s", formatErrors(resp.Errors))
}
return &resp.Result, nil
}
func (c *Cloudflare) doRequest(ctx context.Context, method, endpoint string, payload []byte) ([]byte, error) {
var bodyReader io.Reader
if payload != nil {
bodyReader = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
var errResp cfBaseResponse
if json.Unmarshal(body, &errResp) == nil && len(errResp.Errors) > 0 {
return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, formatErrors(errResp.Errors))
}
return nil, fmt.Errorf("cloudflare API error (%d): %s", resp.StatusCode, string(body))
}
return body, nil
}
// --- Cloudflare API response types ---
type cfBaseResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
}
type cfError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type cfDNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfDNSRecordRequest struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Proxied bool `json:"proxied"`
}
type cfResultInfo struct {
TotalPages int `json:"total_pages"`
}
type cfListResponse struct {
cfBaseResponse
Result []cfDNSRecord `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
}
type cfSingleResponse struct {
cfBaseResponse
Result cfDNSRecord `json:"result"`
}
type cfZone struct {
ID string `json:"id"`
Name string `json:"name"`
}
type cfZonesResponse struct {
cfBaseResponse
Result []cfZone `json:"result"`
ResultInfo cfResultInfo `json:"result_info"`
}
func formatErrors(errs []cfError) string {
if len(errs) == 0 {
return "unknown error"
}
msg := errs[0].Message
for _, e := range errs[1:] {
msg += "; " + e.Message
}
return msg
}