inwx.go raw
1 // Package inwx implements a DNS provider for solving the DNS-01 challenge using inwx dom robot
2 package inwx
3
4 import (
5 "errors"
6 "fmt"
7 "time"
8
9 "github.com/go-acme/lego/v4/challenge"
10 "github.com/go-acme/lego/v4/challenge/dns01"
11 "github.com/go-acme/lego/v4/log"
12 "github.com/go-acme/lego/v4/platform/config/env"
13 "github.com/nrdcg/goinwx"
14 "github.com/pquerna/otp/totp"
15 )
16
17 // Environment variables names.
18 const (
19 envNamespace = "INWX_"
20
21 EnvUsername = envNamespace + "USERNAME"
22 EnvPassword = envNamespace + "PASSWORD"
23 EnvSharedSecret = envNamespace + "SHARED_SECRET"
24 EnvSandbox = envNamespace + "SANDBOX"
25
26 EnvTTL = envNamespace + "TTL"
27 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
28 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
29 )
30
31 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
32
33 // Config is used to configure the creation of the DNSProvider.
34 type Config struct {
35 Username string
36 Password string
37 SharedSecret string
38 Sandbox bool
39 PropagationTimeout time.Duration
40 PollingInterval time.Duration
41 TTL int
42 }
43
44 // NewDefaultConfig returns a default configuration for the DNSProvider.
45 func NewDefaultConfig() *Config {
46 return &Config{
47 TTL: env.GetOrDefaultInt(EnvTTL, 300),
48 // INWX has rather unstable propagation delays, thus using a larger default value
49 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 6*time.Minute),
50 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
51 Sandbox: env.GetOrDefaultBool(EnvSandbox, false),
52 }
53 }
54
55 // DNSProvider implements the challenge.Provider interface.
56 type DNSProvider struct {
57 config *Config
58 client *goinwx.Client
59 previousUnlock time.Time
60 }
61
62 // NewDNSProvider returns a DNSProvider instance configured for Dyn DNS.
63 // Credentials must be passed in the environment variables:
64 // INWX_USERNAME, INWX_PASSWORD, and INWX_SHARED_SECRET.
65 func NewDNSProvider() (*DNSProvider, error) {
66 values, err := env.Get(EnvUsername, EnvPassword)
67 if err != nil {
68 return nil, fmt.Errorf("inwx: %w", err)
69 }
70
71 config := NewDefaultConfig()
72 config.Username = values[EnvUsername]
73 config.Password = values[EnvPassword]
74 config.SharedSecret = env.GetOrFile(EnvSharedSecret)
75
76 return NewDNSProviderConfig(config)
77 }
78
79 // NewDNSProviderConfig return a DNSProvider instance configured for Dyn DNS.
80 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
81 if config == nil {
82 return nil, errors.New("inwx: the configuration of the DNS provider is nil")
83 }
84
85 if config.Username == "" || config.Password == "" {
86 return nil, errors.New("inwx: credentials missing")
87 }
88
89 if config.Sandbox {
90 log.Infof("inwx: sandbox mode is enabled")
91 }
92
93 client := goinwx.NewClient(config.Username, config.Password, &goinwx.ClientOptions{Sandbox: config.Sandbox})
94
95 return &DNSProvider{config: config, client: client}, nil
96 }
97
98 // Present creates a TXT record using the specified parameters.
99 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
100 info := dns01.GetChallengeInfo(domain, keyAuth)
101
102 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
103 if err != nil {
104 return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
105 }
106
107 login, err := d.client.Account.Login()
108 if err != nil {
109 return fmt.Errorf("inwx: %w", err)
110 }
111
112 defer func() {
113 errL := d.client.Account.Logout()
114 if errL != nil {
115 log.Infof("inwx: failed to log out: %v", errL)
116 }
117 }()
118
119 err = d.twoFactorAuth(login)
120 if err != nil {
121 return fmt.Errorf("inwx: %w", err)
122 }
123
124 request := &goinwx.NameserverRecordRequest{
125 Domain: dns01.UnFqdn(authZone),
126 Name: dns01.UnFqdn(info.EffectiveFQDN),
127 Type: "TXT",
128 Content: info.Value,
129 TTL: d.config.TTL,
130 }
131
132 _, err = d.client.Nameservers.CreateRecord(request)
133 if err != nil {
134 var er *goinwx.ErrorResponse
135 if errors.As(err, &er) && er.Message == "Object exists" {
136 return nil
137 }
138
139 return fmt.Errorf("inwx: %w", err)
140 }
141
142 return nil
143 }
144
145 // CleanUp removes the TXT record matching the specified parameters.
146 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
147 info := dns01.GetChallengeInfo(domain, keyAuth)
148
149 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
150 if err != nil {
151 return fmt.Errorf("inwx: could not find zone for domain %q (%s): %w", domain, info.EffectiveFQDN, err)
152 }
153
154 login, err := d.client.Account.Login()
155 if err != nil {
156 return fmt.Errorf("inwx: %w", err)
157 }
158
159 defer func() {
160 errL := d.client.Account.Logout()
161 if errL != nil {
162 log.Infof("inwx: failed to log out: %v", errL)
163 }
164 }()
165
166 err = d.twoFactorAuth(login)
167 if err != nil {
168 return fmt.Errorf("inwx: %w", err)
169 }
170
171 response, err := d.client.Nameservers.Info(&goinwx.NameserverInfoRequest{
172 Domain: dns01.UnFqdn(authZone),
173 Name: dns01.UnFqdn(info.EffectiveFQDN),
174 Type: "TXT",
175 })
176 if err != nil {
177 return fmt.Errorf("inwx: %w", err)
178 }
179
180 var recordID string
181
182 for _, record := range response.Records {
183 if record.Content != info.Value {
184 continue
185 }
186
187 recordID = record.ID
188
189 break
190 }
191
192 if recordID == "" {
193 return errors.New("inwx: TXT record not found")
194 }
195
196 err = d.client.Nameservers.DeleteRecord(recordID)
197 if err != nil {
198 return fmt.Errorf("inwx: %w", err)
199 }
200
201 return nil
202 }
203
204 // Timeout returns the timeout and interval to use when checking for DNS propagation.
205 // Adjusting here to cope with spikes in propagation times.
206 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
207 return d.config.PropagationTimeout, d.config.PollingInterval
208 }
209
210 func (d *DNSProvider) twoFactorAuth(info *goinwx.LoginResponse) error {
211 if info.TFA != "GOOGLE-AUTH" {
212 return nil
213 }
214
215 if d.config.SharedSecret == "" {
216 return errors.New("two-factor authentication but no shared secret is given")
217 }
218
219 // INWX forbids re-authentication with a previously used TAN.
220 // To avoid using the same TAN twice, we wait until the next TOTP period.
221 sleep := d.computeSleep(time.Now())
222 if sleep != 0 {
223 log.Infof("inwx: waiting %s for next TOTP token", sleep)
224 time.Sleep(sleep)
225 }
226
227 now := time.Now()
228
229 tan, err := totp.GenerateCode(d.config.SharedSecret, now)
230 if err != nil {
231 return err
232 }
233
234 d.previousUnlock = now.Truncate(30 * time.Second)
235
236 return d.client.Account.Unlock(tan)
237 }
238
239 func (d *DNSProvider) computeSleep(now time.Time) time.Duration {
240 if d.previousUnlock.IsZero() {
241 return 0
242 }
243
244 endPeriod := d.previousUnlock.Add(30 * time.Second)
245 if endPeriod.After(now) {
246 return endPeriod.Sub(now)
247 }
248
249 return 0
250 }
251