loopia.go raw
1 // Package loopia implements a DNS provider for solving the DNS-01 challenge using loopia DNS.
2 package loopia
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/internal/clientdebug"
16 "github.com/go-acme/lego/v4/providers/dns/loopia/internal"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "LOOPIA_"
22
23 EnvAPIUser = envNamespace + "API_USER"
24 EnvAPIPassword = envNamespace + "API_PASSWORD"
25 EnvAPIURL = envNamespace + "API_URL"
26
27 EnvTTL = envNamespace + "TTL"
28 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
29 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
30 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
31 )
32
33 const minTTL = 300
34
35 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
36
37 type dnsClient interface {
38 AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error
39 RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error
40 GetTXTRecords(ctx context.Context, domain, subdomain string) ([]internal.RecordObj, error)
41 RemoveSubdomain(ctx context.Context, domain, subdomain string) error
42 }
43
44 // Config is used to configure the creation of the DNSProvider.
45 type Config struct {
46 BaseURL string
47 APIUser string
48 APIPassword string
49 PropagationTimeout time.Duration
50 PollingInterval time.Duration
51 TTL int
52 HTTPClient *http.Client
53 }
54
55 // NewDefaultConfig returns a default configuration for the DNSProvider.
56 func NewDefaultConfig() *Config {
57 return &Config{
58 TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
59 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 40*time.Minute),
60 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPropagationTimeout),
61 HTTPClient: &http.Client{
62 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, time.Minute),
63 },
64 }
65 }
66
67 // DNSProvider implements the challenge.Provider interface.
68 type DNSProvider struct {
69 config *Config
70 client dnsClient
71
72 inProgressInfo map[string]int
73 inProgressMu sync.Mutex
74
75 // only for testing purpose.
76 findZoneByFqdn func(fqdn string) (string, error)
77 }
78
79 // NewDNSProvider returns a DNSProvider instance configured for Loopia.
80 // Credentials must be passed in the environment variables:
81 // LOOPIA_API_USER, LOOPIA_API_PASSWORD.
82 func NewDNSProvider() (*DNSProvider, error) {
83 values, err := env.Get(EnvAPIUser, EnvAPIPassword)
84 if err != nil {
85 return nil, fmt.Errorf("loopia: %w", err)
86 }
87
88 config := NewDefaultConfig()
89 config.APIUser = values[EnvAPIUser]
90 config.APIPassword = values[EnvAPIPassword]
91 config.BaseURL = env.GetOrDefaultString(EnvAPIURL, internal.DefaultBaseURL)
92
93 return NewDNSProviderConfig(config)
94 }
95
96 // NewDNSProviderConfig return a DNSProvider instance configured for Loopia.
97 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
98 if config == nil {
99 return nil, errors.New("loopia: the configuration of the DNS provider is nil")
100 }
101
102 if config.APIUser == "" || config.APIPassword == "" {
103 return nil, errors.New("loopia: credentials missing")
104 }
105
106 // Min value for TTL is 300
107 if config.TTL < 300 {
108 config.TTL = 300
109 }
110
111 client := internal.NewClient(config.APIUser, config.APIPassword)
112
113 if config.HTTPClient != nil {
114 client.HTTPClient = config.HTTPClient
115 }
116
117 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
118
119 if config.BaseURL != "" {
120 client.BaseURL = config.BaseURL
121 }
122
123 return &DNSProvider{
124 config: config,
125 client: client,
126 findZoneByFqdn: dns01.FindZoneByFqdn,
127 inProgressInfo: make(map[string]int),
128 }, nil
129 }
130
131 // Timeout returns the timeout and interval to use when checking for DNS propagation.
132 // Adjusting here to cope with spikes in propagation times.
133 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
134 return d.config.PropagationTimeout, d.config.PollingInterval
135 }
136
137 // Present creates a TXT record using the specified parameters.
138 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
139 info := dns01.GetChallengeInfo(domain, keyAuth)
140
141 subDomain, authZone, err := d.splitDomain(info.EffectiveFQDN)
142 if err != nil {
143 return fmt.Errorf("loopia: %w", err)
144 }
145
146 ctx := context.Background()
147
148 err = d.client.AddTXTRecord(ctx, authZone, subDomain, d.config.TTL, info.Value)
149 if err != nil {
150 return fmt.Errorf("loopia: failed to add TXT record: %w", err)
151 }
152
153 txtRecords, err := d.client.GetTXTRecords(ctx, authZone, subDomain)
154 if err != nil {
155 return fmt.Errorf("loopia: failed to get TXT records: %w", err)
156 }
157
158 d.inProgressMu.Lock()
159 defer d.inProgressMu.Unlock()
160
161 for _, r := range txtRecords {
162 if r.Rdata == info.Value {
163 d.inProgressInfo[token] = r.RecordID
164 return nil
165 }
166 }
167
168 return errors.New("loopia: failed to find the stored TXT record")
169 }
170
171 // CleanUp removes the TXT record matching the specified parameters.
172 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
173 info := dns01.GetChallengeInfo(domain, keyAuth)
174
175 subDomain, authZone, err := d.splitDomain(info.EffectiveFQDN)
176 if err != nil {
177 return fmt.Errorf("loopia: %w", err)
178 }
179
180 d.inProgressMu.Lock()
181 defer d.inProgressMu.Unlock()
182
183 ctx := context.Background()
184
185 err = d.client.RemoveTXTRecord(ctx, authZone, subDomain, d.inProgressInfo[token])
186 if err != nil {
187 return fmt.Errorf("loopia: failed to remove TXT record: %w", err)
188 }
189
190 records, err := d.client.GetTXTRecords(ctx, authZone, subDomain)
191 if err != nil {
192 return fmt.Errorf("loopia: failed to get TXT records: %w", err)
193 }
194
195 if len(records) > 0 {
196 return nil
197 }
198
199 err = d.client.RemoveSubdomain(ctx, authZone, subDomain)
200 if err != nil {
201 return fmt.Errorf("loopia: failed to remove subdomain: %w", err)
202 }
203
204 return nil
205 }
206
207 func (d *DNSProvider) splitDomain(fqdn string) (string, string, error) {
208 authZone, err := d.findZoneByFqdn(fqdn)
209 if err != nil {
210 return "", "", fmt.Errorf("could not find zone: %w", err)
211 }
212
213 subDomain, err := dns01.ExtractSubDomain(fqdn, authZone)
214 if err != nil {
215 return "", "", err
216 }
217
218 return subDomain, dns01.UnFqdn(authZone), nil
219 }
220