1// Package hetzner implements a DNS provider for solving the DNS-01 challenge using Hetzner DNS. 2package hetzner 3 4import ( 5 "errors" 6 "fmt" 7 "net/http" 8 "strings" 9 "time" 10 11 "github.com/go-acme/lego/v4/challenge/dns01" 12 "github.com/go-acme/lego/v4/platform/config/env" 13 "github.com/go-acme/lego/v4/providers/dns/hetzner/internal" 14) 15 16const minTTL = 600 17 18// Environment variables names. 19const ( 20 envNamespace = "HETZNER_" 21 22 EnvAPIKey = envNamespace + "API_KEY" 23 24 EnvTTL = envNamespace + "TTL" 25 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" 26 EnvPollingInterval = envNamespace + "POLLING_INTERVAL" 27 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" 28) 29 30// Config is used to configure the creation of the DNSProvider. 31type Config struct { 32 APIKey string 33 PropagationTimeout time.Duration 34 PollingInterval time.Duration 35 TTL int 36 HTTPClient *http.Client 37} 38 39// NewDefaultConfig returns a default configuration for the DNSProvider. 40func NewDefaultConfig() *Config { 41 return &Config{ 42 TTL: env.GetOrDefaultInt(EnvTTL, minTTL), 43 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second), 44 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), 45 HTTPClient: &http.Client{ 46 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), 47 }, 48 } 49} 50 51// DNSProvider implements the challenge.Provider interface. 52type DNSProvider struct { 53 config *Config 54 client *internal.Client 55} 56 57// NewDNSProvider returns a DNSProvider instance configured for hetzner. 58// Credentials must be passed in the environment variable: HETZNER_API_KEY. 59func NewDNSProvider() (*DNSProvider, error) { 60 values, err := env.Get(EnvAPIKey) 61 if err != nil { 62 return nil, fmt.Errorf("hetzner: %w", err) 63 } 64 65 config := NewDefaultConfig() 66 config.APIKey = values[EnvAPIKey] 67 68 return NewDNSProviderConfig(config) 69} 70 71// NewDNSProviderConfig return a DNSProvider instance configured for hetzner. 72func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { 73 if config == nil { 74 return nil, errors.New("hetzner: the configuration of the DNS provider is nil") 75 } 76 77 if config.APIKey == "" { 78 return nil, errors.New("hetzner: credentials missing") 79 } 80 81 if config.TTL < minTTL { 82 return nil, fmt.Errorf("hetzner: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL) 83 } 84 85 client := internal.NewClient(config.APIKey) 86 87 if config.HTTPClient != nil { 88 client.HTTPClient = config.HTTPClient 89 } 90 91 return &DNSProvider{config: config, client: client}, nil 92} 93 94// Timeout returns the timeout and interval to use when checking for DNS 95// propagation. Adjusting here to cope with spikes in propagation times. 96func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 97 return d.config.PropagationTimeout, d.config.PollingInterval 98} 99 100// Present creates a TXT record to fulfill the dns-01 challenge. 101func (d *DNSProvider) Present(domain, token, keyAuth string) error { 102 fqdn, value := dns01.GetRecord(domain, keyAuth) 103 104 zone, err := getZone(fqdn) 105 if err != nil { 106 return fmt.Errorf("hetzner: failed to find zone: fqdn=%s: %w", fqdn, err) 107 } 108 109 zoneID, err := d.client.GetZoneID(zone) 110 if err != nil { 111 return fmt.Errorf("hetzner: %w", err) 112 } 113 114 record := internal.DNSRecord{ 115 Type: "TXT", 116 Name: extractRecordName(fqdn, zone), 117 Value: value, 118 TTL: d.config.TTL, 119 ZoneID: zoneID, 120 } 121 122 if err := d.client.CreateRecord(record); err != nil { 123 return fmt.Errorf("hetzner: failed to add TXT record: fqdn=%s, zoneID=%s: %w", fqdn, zoneID, err) 124 } 125 126 return nil 127} 128 129// CleanUp removes the TXT record matching the specified parameters. 130func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { 131 fqdn, value := dns01.GetRecord(domain, keyAuth) 132 133 zone, err := getZone(fqdn) 134 if err != nil { 135 return fmt.Errorf("hetzner: failed to find zone: fqdn=%s: %w", fqdn, err) 136 } 137 138 zoneID, err := d.client.GetZoneID(zone) 139 if err != nil { 140 return fmt.Errorf("hetzner: %w", err) 141 } 142 143 recordName := extractRecordName(fqdn, zone) 144 145 record, err := d.client.GetTxtRecord(recordName, value, zoneID) 146 if err != nil { 147 return fmt.Errorf("hetzner: %w", err) 148 } 149 150 if err := d.client.DeleteRecord(record.ID); err != nil { 151 return fmt.Errorf("hetzner: failed to delate TXT record: id=%s, name=%s: %w", record.ID, record.Name, err) 152 } 153 154 return nil 155} 156 157func extractRecordName(fqdn, zone string) string { 158 name := dns01.UnFqdn(fqdn) 159 if idx := strings.Index(name, "."+zone); idx != -1 { 160 return name[:idx] 161 } 162 return name 163} 164 165func getZone(fqdn string) (string, error) { 166 authZone, err := dns01.FindZoneByFqdn(fqdn) 167 if err != nil { 168 return "", err 169 } 170 171 return dns01.UnFqdn(authZone), nil 172} 173