c730cfaa45
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
368 lines
8.9 KiB
Go
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
|
|
}
|