// Package dns provides a provider-agnostic interface for DNS // record management, with a Cloudflare implementation. // // Adding a new provider (Route53, Porkbun, etc.): // 1. Implement the Provider interface below. // 2. Add a case in NewProvider(). // 3. The rest of kforge uses Provider — no other changes needed. package dns import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" "kforge/internal/config" ) // ------------------------------------------------------------ // Provider interface // ------------------------------------------------------------ // Provider is the DNS provider contract. Implementations must // be idempotent — calling EnsureARecord twice with the same // inputs must not error or create duplicates. type Provider interface { // EnsureARecord creates an A record for hostname pointing to // ip if one does not already exist. If a record exists with a // different IP, it is updated. No-ops if already correct. EnsureARecord(hostname, ip string) error // DeleteARecord removes the A record for hostname if it exists. // No-ops if it does not exist. DeleteARecord(hostname string) error } // NewProvider returns the configured DNS provider. func NewProvider(cfg config.DNSConfig) (Provider, error) { switch cfg.Provider { case "cloudflare": return newCloudflareProvider(cfg) case "": return &noopProvider{}, nil default: return nil, fmt.Errorf("unknown dns provider %q (supported: cloudflare)", cfg.Provider) } } // noopProvider satisfies the interface when DNS management is // disabled (skip_dns: true or no provider configured). type noopProvider struct{} func (n *noopProvider) EnsureARecord(hostname, ip string) error { return nil } func (n *noopProvider) DeleteARecord(hostname string) error { return nil } // ------------------------------------------------------------ // Cloudflare implementation // ------------------------------------------------------------ const cfAPIBase = "https://api.cloudflare.com/client/v4" type cloudflareProvider struct { apiToken string zones []config.ZoneEntry // sorted longest-first for matching proxied bool client *http.Client } func newCloudflareProvider(cfg config.DNSConfig) (*cloudflareProvider, error) { if cfg.Cloudflare.APIToken == "" { return nil, fmt.Errorf("cloudflare.api_token is required") } if len(cfg.Cloudflare.Zones) == 0 { return nil, fmt.Errorf("cloudflare.zones must have at least one entry") } return &cloudflareProvider{ apiToken: cfg.Cloudflare.APIToken, zones: cfg.Cloudflare.Zones, proxied: cfg.Cloudflare.Proxied, client: &http.Client{Timeout: 15 * time.Second}, }, nil } // zoneForHostname finds the zone whose name is the longest suffix // of hostname. This handles both "app.example.com" → "example.com" // and "app.sub.example.co.uk" → "example.co.uk" if that zone exists. func (c *cloudflareProvider) zoneForHostname(hostname string) (config.ZoneEntry, error) { var best config.ZoneEntry bestLen := 0 for _, z := range c.zones { if strings.HasSuffix(hostname, z.Name) && len(z.Name) > bestLen { best = z bestLen = len(z.Name) } } if bestLen == 0 { return config.ZoneEntry{}, fmt.Errorf("no configured zone matches hostname %q", hostname) } return best, nil } // EnsureARecord is idempotent: creates if absent, updates if IP // differs, no-ops if already correct. func (c *cloudflareProvider) EnsureARecord(hostname, ip string) error { zone, err := c.zoneForHostname(hostname) if err != nil { return err } existing, err := c.getRecord(zone.ZoneID, hostname, "A") if err != nil { return fmt.Errorf("checking existing record: %w", err) } if existing != nil { if existing.Content == ip { fmt.Printf(" dns: A record %s → %s already correct, skipping\n", hostname, ip) return nil } fmt.Printf(" dns: updating A record %s → %s (was %s)\n", hostname, ip, existing.Content) return c.updateRecord(zone.ZoneID, existing.ID, hostname, ip) } fmt.Printf(" dns: creating A record %s → %s\n", hostname, ip) return c.createRecord(zone.ZoneID, hostname, ip) } // DeleteARecord removes the A record for hostname if it exists. func (c *cloudflareProvider) DeleteARecord(hostname string) error { zone, err := c.zoneForHostname(hostname) if err != nil { return err } existing, err := c.getRecord(zone.ZoneID, hostname, "A") if err != nil { return fmt.Errorf("checking existing record: %w", err) } if existing == nil { fmt.Printf(" dns: A record %s not found, skipping delete\n", hostname) return nil } fmt.Printf(" dns: deleting A record %s\n", hostname) return c.deleteRecord(zone.ZoneID, existing.ID) } // ------------------------------------------------------------ // Cloudflare API helpers // ------------------------------------------------------------ type cfRecord struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` Proxied bool `json:"proxied"` TTL int `json:"ttl"` } type cfListResponse struct { Success bool `json:"success"` Errors []cfError `json:"errors"` Result []cfRecord `json:"result"` } type cfSingleResponse struct { Success bool `json:"success"` Errors []cfError `json:"errors"` Result cfRecord `json:"result"` } type cfError struct { Code int `json:"code"` Message string `json:"message"` } func (e cfError) Error() string { return fmt.Sprintf("CF %d: %s", e.Code, e.Message) } func (c *cloudflareProvider) getRecord(zoneID, name, recType string) (*cfRecord, error) { url := fmt.Sprintf("%s/zones/%s/dns_records?type=%s&name=%s", cfAPIBase, zoneID, recType, name) resp, err := c.do("GET", url, nil) if err != nil { return nil, err } defer resp.Body.Close() var list cfListResponse if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } if !list.Success { return nil, cfErrors(list.Errors) } if len(list.Result) == 0 { return nil, nil } return &list.Result[0], nil } func (c *cloudflareProvider) createRecord(zoneID, name, ip string) error { body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`, name, ip, c.proxied) url := fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID) resp, err := c.do("POST", url, strings.NewReader(body)) if err != nil { return err } defer resp.Body.Close() var result cfSingleResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return fmt.Errorf("decoding response: %w", err) } if !result.Success { return cfErrors(result.Errors) } return nil } func (c *cloudflareProvider) updateRecord(zoneID, recordID, name, ip string) error { body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`, name, ip, c.proxied) url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID) resp, err := c.do("PUT", url, strings.NewReader(body)) if err != nil { return err } defer resp.Body.Close() var result cfSingleResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return fmt.Errorf("decoding response: %w", err) } if !result.Success { return cfErrors(result.Errors) } return nil } func (c *cloudflareProvider) deleteRecord(zoneID, recordID string) error { url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID) resp, err := c.do("DELETE", url, nil) if err != nil { return err } defer resp.Body.Close() // Cloudflare returns {"result":{"id":"..."}} on success — we // don't need to parse it, just check for HTTP errors. body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return fmt.Errorf("delete failed (%d): %s", resp.StatusCode, string(body)) } return nil } func (c *cloudflareProvider) do(method, url string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.apiToken) req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("cloudflare API %s %s: %w", method, url, err) } return resp, nil } func cfErrors(errs []cfError) error { msgs := make([]string, len(errs)) for i, e := range errs { msgs[i] = e.Error() } return fmt.Errorf("cloudflare API errors: %s", strings.Join(msgs, "; ")) } // ------------------------------------------------------------ // EnsureRecordsForEnvironment — high-level helper used by // the apply command and generate pipeline. // ------------------------------------------------------------ // EnsureRecordsForEnvironment creates A records for all ingress // hosts in the environment that have dns_record: true. func EnsureRecordsForEnvironment(provider Provider, env *config.ResolvedEnvironment, nodeIP string) error { for _, host := range env.Ingress.Hosts { if !host.DNSRecord { continue } if err := provider.EnsureARecord(host.Hostname, nodeIP); err != nil { return fmt.Errorf("ensuring A record for %s: %w", host.Hostname, err) } } return nil }