gandi.go raw
1 // Package gandi implements a DNS provider for solving the DNS-01 challenge using Gandi DNS.
2 package gandi
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "sync"
10 "time"
11
12 "github.com/go-acme/lego/v4/challenge"
13 "github.com/go-acme/lego/v4/challenge/dns01"
14 "github.com/go-acme/lego/v4/platform/config/env"
15 "github.com/go-acme/lego/v4/providers/dns/gandi/internal"
16 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "GANDI_"
22
23 EnvAPIKey = envNamespace + "API_KEY"
24
25 EnvTTL = envNamespace + "TTL"
26 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
27 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
28 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
29 )
30
31 const minTTL = 300
32
33 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
34
35 // Config is used to configure the creation of the DNSProvider.
36 type Config struct {
37 BaseURL string
38 APIKey string
39 PropagationTimeout time.Duration
40 PollingInterval time.Duration
41 TTL int
42 HTTPClient *http.Client
43 }
44
45 // NewDefaultConfig returns a default configuration for the DNSProvider.
46 func NewDefaultConfig() *Config {
47 return &Config{
48 TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
49 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute),
50 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 60*time.Second),
51 HTTPClient: &http.Client{
52 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second),
53 },
54 }
55 }
56
57 // inProgressInfo contains information about an in-progress challenge.
58 type inProgressInfo struct {
59 zoneID int // zoneID of gandi zone to restore in CleanUp
60 newZoneID int // zoneID of temporary gandi zone containing TXT record
61 authZone string // the domain name registered at gandi with trailing "."
62 }
63
64 // DNSProvider implements the challenge.Provider interface.
65 type DNSProvider struct {
66 config *Config
67 client *internal.Client
68
69 inProgressFQDNs map[string]inProgressInfo
70 inProgressAuthZones map[string]struct{}
71 inProgressMu sync.Mutex
72
73 // findZoneByFqdn determines the DNS zone of a FQDN.
74 // It is overridden during tests.
75 // only for testing purpose.
76 findZoneByFqdn func(fqdn string) (string, error)
77 }
78
79 // NewDNSProvider returns a DNSProvider instance configured for Gandi.
80 // Credentials must be passed in the environment variable: GANDI_API_KEY.
81 func NewDNSProvider() (*DNSProvider, error) {
82 values, err := env.Get(EnvAPIKey)
83 if err != nil {
84 return nil, fmt.Errorf("gandi: %w", err)
85 }
86
87 config := NewDefaultConfig()
88 config.APIKey = values[EnvAPIKey]
89
90 return NewDNSProviderConfig(config)
91 }
92
93 // NewDNSProviderConfig return a DNSProvider instance configured for Gandi.
94 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
95 if config == nil {
96 return nil, errors.New("gandi: the configuration of the DNS provider is nil")
97 }
98
99 if config.APIKey == "" {
100 return nil, errors.New("gandi: no API Key given")
101 }
102
103 client := internal.NewClient(config.APIKey)
104
105 if config.BaseURL != "" {
106 client.BaseURL = config.BaseURL
107 }
108
109 if config.HTTPClient != nil {
110 client.HTTPClient = config.HTTPClient
111 }
112
113 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
114
115 return &DNSProvider{
116 config: config,
117 client: client,
118 inProgressFQDNs: make(map[string]inProgressInfo),
119 inProgressAuthZones: make(map[string]struct{}),
120 findZoneByFqdn: dns01.FindZoneByFqdn,
121 }, nil
122 }
123
124 // Present creates a TXT record using the specified parameters. It
125 // does this by creating and activating a new temporary Gandi DNS
126 // zone. This new zone contains the TXT record.
127 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
128 info := dns01.GetChallengeInfo(domain, keyAuth)
129
130 if d.config.TTL < minTTL {
131 d.config.TTL = minTTL // 300 is gandi minimum value for ttl
132 }
133
134 // find authZone and Gandi zone_id for fqdn
135 authZone, err := d.findZoneByFqdn(info.EffectiveFQDN)
136 if err != nil {
137 return fmt.Errorf("gandi: could not find zone for domain %q: %w", domain, err)
138 }
139
140 ctx := context.Background()
141
142 zoneID, err := d.client.GetZoneID(ctx, authZone)
143 if err != nil {
144 return fmt.Errorf("gandi: %w", err)
145 }
146
147 // determine name of TXT record
148 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
149 if err != nil {
150 return fmt.Errorf("gandi: %w", err)
151 }
152
153 // acquire lock and check there is not a challenge already in
154 // progress for this value of authZone
155 d.inProgressMu.Lock()
156 defer d.inProgressMu.Unlock()
157
158 if _, ok := d.inProgressAuthZones[authZone]; ok {
159 return fmt.Errorf("gandi: challenge already in progress for authZone %s", authZone)
160 }
161
162 // perform API actions to create and activate new gandi zone
163 // containing the required TXT record
164 newZoneName := fmt.Sprintf("%s [ACME Challenge %s]", dns01.UnFqdn(authZone), time.Now().Format(time.RFC822Z))
165
166 newZoneID, err := d.client.CloneZone(ctx, zoneID, newZoneName)
167 if err != nil {
168 return err
169 }
170
171 newZoneVersion, err := d.client.NewZoneVersion(ctx, newZoneID)
172 if err != nil {
173 return fmt.Errorf("gandi: %w", err)
174 }
175
176 err = d.client.AddTXTRecord(ctx, newZoneID, newZoneVersion, subDomain, info.Value, d.config.TTL)
177 if err != nil {
178 return fmt.Errorf("gandi: %w", err)
179 }
180
181 err = d.client.SetZoneVersion(ctx, newZoneID, newZoneVersion)
182 if err != nil {
183 return fmt.Errorf("gandi: %w", err)
184 }
185
186 err = d.client.SetZone(ctx, authZone, newZoneID)
187 if err != nil {
188 return fmt.Errorf("gandi: %w", err)
189 }
190
191 // save data necessary for CleanUp
192 d.inProgressFQDNs[info.EffectiveFQDN] = inProgressInfo{
193 zoneID: zoneID,
194 newZoneID: newZoneID,
195 authZone: authZone,
196 }
197 d.inProgressAuthZones[authZone] = struct{}{}
198
199 return nil
200 }
201
202 // CleanUp removes the TXT record matching the specified
203 // parameters. It does this by restoring the old Gandi DNS zone and
204 // removing the temporary one created by Present.
205 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
206 info := dns01.GetChallengeInfo(domain, keyAuth)
207
208 // acquire lock and retrieve zoneID, newZoneID and authZone
209 d.inProgressMu.Lock()
210 defer d.inProgressMu.Unlock()
211
212 if _, ok := d.inProgressFQDNs[info.EffectiveFQDN]; !ok {
213 // if there is no cleanup information then just return
214 return nil
215 }
216
217 zoneID := d.inProgressFQDNs[info.EffectiveFQDN].zoneID
218 newZoneID := d.inProgressFQDNs[info.EffectiveFQDN].newZoneID
219 authZone := d.inProgressFQDNs[info.EffectiveFQDN].authZone
220 delete(d.inProgressFQDNs, info.EffectiveFQDN)
221 delete(d.inProgressAuthZones, authZone)
222
223 ctx := context.Background()
224
225 // perform API actions to restore old gandi zone for authZone
226 err := d.client.SetZone(ctx, authZone, zoneID)
227 if err != nil {
228 return fmt.Errorf("gandi: %w", err)
229 }
230
231 return d.client.DeleteZone(ctx, newZoneID)
232 }
233
234 // Timeout returns the values (40*time.Minute, 60*time.Second) which
235 // are used by the acme package as timeout and check interval values
236 // when checking for DNS record propagation with Gandi.
237 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
238 return d.config.PropagationTimeout, d.config.PollingInterval
239 }
240