shellrent.go raw
1 package shellrent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "strings"
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/shellrent/internal"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "SHELLRENT_"
22
23 EnvUsername = envNamespace + "USERNAME"
24 EnvToken = envNamespace + "TOKEN"
25
26 EnvTTL = envNamespace + "TTL"
27 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
28 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
29 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
30 )
31
32 const defaultTTL = 3600
33
34 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
35
36 type reqKey struct {
37 domainID int
38 recordID int
39 }
40
41 // Config is used to configure the creation of the DNSProvider.
42 type Config struct {
43 Username string
44 Token string
45 PropagationTimeout time.Duration
46 PollingInterval time.Duration
47 TTL int
48 HTTPClient *http.Client
49 }
50
51 // NewDefaultConfig returns a default configuration for the DNSProvider.
52 func NewDefaultConfig() *Config {
53 return &Config{
54 TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
55 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
56 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
57 HTTPClient: &http.Client{
58 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
59 },
60 }
61 }
62
63 // DNSProvider implements the challenge.Provider interface.
64 type DNSProvider struct {
65 config *Config
66 client *internal.Client
67
68 recordIDs map[string]reqKey
69 recordIDsMu sync.Mutex
70 }
71
72 // NewDNSProvider returns a DNSProvider instance configured for Shellrent.
73 // Credentials must be passed in the environment variable: SHELLRENT_USERNAME, SHELLRENT_TOKEN.
74 func NewDNSProvider() (*DNSProvider, error) {
75 values, err := env.Get(EnvUsername, EnvToken)
76 if err != nil {
77 return nil, fmt.Errorf("shellrent: %w", err)
78 }
79
80 config := NewDefaultConfig()
81 config.Username = values[EnvUsername]
82 config.Token = values[EnvToken]
83
84 return NewDNSProviderConfig(config)
85 }
86
87 // NewDNSProviderConfig return a DNSProvider instance configured for Shellrent.
88 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
89 if config == nil {
90 return nil, errors.New("shellrent: the configuration of the DNS provider is nil")
91 }
92
93 if config.Username == "" {
94 return nil, errors.New("shellrent: missing credentials: username")
95 }
96
97 if config.Token == "" {
98 return nil, errors.New("shellrent: missing credentials: token")
99 }
100
101 client := internal.NewClient(config.Username, config.Token)
102
103 if config.HTTPClient != nil {
104 client.HTTPClient = config.HTTPClient
105 }
106
107 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
108
109 return &DNSProvider{
110 config: config,
111 client: client,
112 recordIDs: make(map[string]reqKey),
113 }, nil
114 }
115
116 // Timeout returns the timeout and interval to use when checking for DNS propagation.
117 // Adjusting here to cope with spikes in propagation times.
118 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
119 return d.config.PropagationTimeout, d.config.PollingInterval
120 }
121
122 // Present creates a TXT record using the specified parameters.
123 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
124 ctx := context.Background()
125 info := dns01.GetChallengeInfo(domain, keyAuth)
126
127 zone, err := d.findZone(ctx, dns01.UnFqdn(info.EffectiveFQDN))
128 if err != nil {
129 return fmt.Errorf("shellrent: could not find zone for domain %q: %w", domain, err)
130 }
131
132 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, zone.DomainName)
133 if err != nil {
134 return fmt.Errorf("shellrent: %w", err)
135 }
136
137 record := internal.Record{
138 Type: "TXT",
139 Host: subDomain,
140 TTL: internal.TTLRounder(d.config.TTL),
141 Destination: info.Value,
142 }
143
144 recordID, err := d.client.CreateRecord(ctx, zone.ID, record)
145 if err != nil {
146 return fmt.Errorf("shellrent: create record: %w", err)
147 }
148
149 d.recordIDsMu.Lock()
150 d.recordIDs[token] = reqKey{domainID: zone.ID, recordID: recordID}
151 d.recordIDsMu.Unlock()
152
153 return nil
154 }
155
156 // CleanUp removes the TXT record matching the specified parameters.
157 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
158 ctx := context.Background()
159 info := dns01.GetChallengeInfo(domain, keyAuth)
160
161 // gets the record's unique ID from when we created it
162 d.recordIDsMu.Lock()
163 key, ok := d.recordIDs[token]
164 d.recordIDsMu.Unlock()
165
166 if !ok {
167 return fmt.Errorf("shellrent: unknown request key for '%s' '%s'", info.EffectiveFQDN, token)
168 }
169
170 err := d.client.DeleteRecord(ctx, key.domainID, key.recordID)
171 if err != nil {
172 return fmt.Errorf("shellrent: delete record: %w", err)
173 }
174
175 d.recordIDsMu.Lock()
176 delete(d.recordIDs, token)
177 d.recordIDsMu.Unlock()
178
179 return nil
180 }
181
182 func (d *DNSProvider) findZone(ctx context.Context, domain string) (*internal.DomainDetails, error) {
183 services, err := d.client.ListServices(ctx)
184 if err != nil {
185 return nil, fmt.Errorf("list services: %w", err)
186 }
187
188 for _, service := range services {
189 details, err := d.client.GetServiceDetails(ctx, service)
190 if err != nil {
191 return nil, fmt.Errorf("get service details: %w", err)
192 }
193
194 domainDetails, err := d.client.GetDomainDetails(ctx, details.DomainID)
195 if err != nil {
196 return nil, fmt.Errorf("get domain details: %w", err)
197 }
198
199 domain := domain
200
201 for {
202 i := strings.Index(domain, ".")
203 if i == -1 {
204 break
205 }
206
207 if strings.EqualFold(domainDetails.DomainName, domain) {
208 return domainDetails, nil
209 }
210
211 domain = domain[i+1:]
212 }
213 }
214
215 return nil, errors.New("zone not found")
216 }
217