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