302 lines
8.9 KiB
Go
302 lines
8.9 KiB
Go
// 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
|
|
}
|