gravity.go raw
1 // Package gravity implements a DNS provider for solving the DNS-01 challenge using Gravity.
2 package gravity
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/dns01"
13 "github.com/go-acme/lego/v4/platform/config/env"
14 "github.com/go-acme/lego/v4/providers/dns/gravity/internal"
15 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
16 "github.com/google/uuid"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "GRAVITY_"
22
23 EnvUsername = envNamespace + "USERNAME"
24 EnvPassword = envNamespace + "PASSWORD"
25 EnvServerURL = envNamespace + "SERVER_URL"
26
27 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
28 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
29 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
30 EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL"
31 )
32
33 // Config is used to configure the creation of the DNSProvider.
34 type Config struct {
35 Username string
36 Password string
37 ServerURL string
38
39 PropagationTimeout time.Duration
40 PollingInterval time.Duration
41 SequenceInterval time.Duration
42 HTTPClient *http.Client
43 }
44
45 // NewDefaultConfig returns a default configuration for the DNSProvider.
46 func NewDefaultConfig() *Config {
47 return &Config{
48 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
49 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
50 SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 1*time.Second),
51 HTTPClient: &http.Client{
52 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
53 },
54 }
55 }
56
57 // DNSProvider implements the challenge.Provider interface.
58 type DNSProvider struct {
59 config *Config
60 client *internal.Client
61
62 records map[string]internal.Record
63 recordsMu sync.Mutex
64 }
65
66 // NewDNSProvider returns a DNSProvider instance configured for Gravity.
67 func NewDNSProvider() (*DNSProvider, error) {
68 values, err := env.Get(EnvUsername, EnvPassword, EnvServerURL)
69 if err != nil {
70 return nil, fmt.Errorf("gravity: %w", err)
71 }
72
73 config := NewDefaultConfig()
74 config.Username = values[EnvUsername]
75 config.Password = values[EnvPassword]
76 config.ServerURL = values[EnvServerURL]
77
78 return NewDNSProviderConfig(config)
79 }
80
81 // NewDNSProviderConfig return a DNSProvider instance configured for Gravity.
82 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
83 if config == nil {
84 return nil, errors.New("gravity: the configuration of the DNS provider is nil")
85 }
86
87 client, err := internal.NewClient(config.ServerURL, config.Username, config.Password)
88 if err != nil {
89 return nil, fmt.Errorf("gravity: %w", err)
90 }
91
92 if config.HTTPClient != nil {
93 client.HTTPClient = config.HTTPClient
94 }
95
96 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
97
98 return &DNSProvider{
99 config: config,
100 client: client,
101 records: make(map[string]internal.Record),
102 }, nil
103 }
104
105 // Present creates a TXT record using the specified parameters.
106 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
107 ctx := context.Background()
108
109 info := dns01.GetChallengeInfo(domain, keyAuth)
110
111 _, err := d.client.Login(ctx)
112 if err != nil {
113 return fmt.Errorf("gravity: login: %w", err)
114 }
115
116 zone, err := d.findZone(ctx, info.EffectiveFQDN)
117 if err != nil {
118 return fmt.Errorf("gravity: %w", err)
119 }
120
121 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone)
122 if err != nil {
123 return fmt.Errorf("gravity: %w", err)
124 }
125
126 id := uuid.New()
127
128 record := internal.Record{
129 Data: info.Value,
130 Hostname: subDomain,
131 Type: "TXT",
132 UID: id.String(),
133 }
134
135 err = d.client.CreateDNSRecord(ctx, zone, record)
136 if err != nil {
137 return fmt.Errorf("gravity: create DNS record: %w", err)
138 }
139
140 d.recordsMu.Lock()
141
142 record.Fqdn = zone
143 d.records[token] = record
144 d.recordsMu.Unlock()
145
146 return nil
147 }
148
149 // CleanUp removes the TXT record matching the specified parameters.
150 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
151 info := dns01.GetChallengeInfo(domain, keyAuth)
152
153 d.recordsMu.Lock()
154 record, ok := d.records[token]
155 d.recordsMu.Unlock()
156
157 if !ok {
158 return fmt.Errorf("gravity: unknown record for '%s' '%s'", info.EffectiveFQDN, token)
159 }
160
161 err := d.client.DeleteDNSRecord(context.Background(), record.Fqdn, record)
162 if err != nil {
163 return fmt.Errorf("gravity: delete record: %w", err)
164 }
165
166 d.recordsMu.Lock()
167 delete(d.records, token)
168 d.recordsMu.Unlock()
169
170 return nil
171 }
172
173 // Timeout returns the timeout and interval to use when checking for DNS propagation.
174 // Adjusting here to cope with spikes in propagation times.
175 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
176 return d.config.PropagationTimeout, d.config.PollingInterval
177 }
178
179 // Sequential implements the [dns01.sequential] interface.
180 // It changes the behavior of the provider to resolve DNS challenges sequentially.
181 // Returns the interval between each iteration.
182 //
183 // Gravity supports adding multiple records for the same domain, but the DNS server doesn't work as expected:
184 // if you call the DNS server, it will answer only the latest record instead of all of them.
185 func (d *DNSProvider) Sequential() time.Duration {
186 return d.config.SequenceInterval
187 }
188
189 func (d *DNSProvider) findZone(ctx context.Context, effectiveFQDN string) (string, error) {
190 var zone string
191
192 for fqdn := range dns01.DomainsSeq(effectiveFQDN) {
193 zones, err := d.client.GetDNSZones(ctx, fqdn)
194 if err != nil {
195 return "", fmt.Errorf("get DNS zones: %w", err)
196 }
197
198 if len(zones) != 0 {
199 zone = zones[0].Name
200 break
201 }
202 }
203
204 if zone == "" {
205 return "", fmt.Errorf("could not find zone for %q", effectiveFQDN)
206 }
207
208 return zone, nil
209 }
210