liquidweb.go raw
1 // Package liquidweb implements a DNS provider for solving the DNS-01 challenge using Liquid Web.
2 package liquidweb
3
4 import (
5 "errors"
6 "fmt"
7 "sort"
8 "strconv"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/go-acme/lego/v4/challenge"
14 "github.com/go-acme/lego/v4/challenge/dns01"
15 "github.com/go-acme/lego/v4/platform/config/env"
16 lw "github.com/liquidweb/liquidweb-go/client"
17 "github.com/liquidweb/liquidweb-go/network"
18 )
19
20 // Environment variables names.
21 const (
22 envNamespace = "LIQUID_WEB_"
23 altEnvNamespace = "LWAPI_"
24
25 EnvURL = envNamespace + "URL"
26 EnvUsername = envNamespace + "USERNAME"
27 EnvPassword = envNamespace + "PASSWORD"
28 EnvZone = envNamespace + "ZONE"
29
30 EnvTTL = envNamespace + "TTL"
31 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
32 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
33 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
34 )
35
36 const defaultBaseURL = "https://api.liquidweb.com"
37
38 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
39
40 // Config is used to configure the creation of the DNSProvider.
41 type Config struct {
42 BaseURL string
43 Username string
44 Password string
45 Zone string
46 TTL int
47 PollingInterval time.Duration
48 PropagationTimeout time.Duration
49 HTTPTimeout time.Duration
50 }
51
52 // NewDefaultConfig returns a default configuration for the DNSProvider.
53 func NewDefaultConfig() *Config {
54 return &Config{
55 BaseURL: defaultBaseURL,
56 TTL: env.GetOneWithFallback(EnvTTL, 300, strconv.Atoi, altEnvName(EnvTTL)),
57 PropagationTimeout: env.GetOneWithFallback(EnvPropagationTimeout, 2*time.Minute, env.ParseSecond, altEnvName(EnvPropagationTimeout)),
58 PollingInterval: env.GetOneWithFallback(EnvPollingInterval, dns01.DefaultPollingInterval, env.ParseSecond, altEnvName(EnvPollingInterval)),
59 HTTPTimeout: env.GetOneWithFallback(EnvHTTPTimeout, 1*time.Minute, env.ParseSecond, altEnvName(EnvHTTPTimeout)),
60 }
61 }
62
63 // DNSProvider implements the challenge.Provider interface.
64 type DNSProvider struct {
65 config *Config
66 client *lw.API
67
68 recordIDs map[string]int
69 recordIDsMu sync.Mutex
70 }
71
72 // NewDNSProvider returns a DNSProvider instance configured for Liquid Web.
73 func NewDNSProvider() (*DNSProvider, error) {
74 values, err := env.GetWithFallback(
75 []string{EnvUsername, altEnvName(EnvUsername)},
76 []string{EnvPassword, altEnvName(EnvPassword)},
77 )
78 if err != nil {
79 return nil, fmt.Errorf("liquidweb: %w", err)
80 }
81
82 config := NewDefaultConfig()
83 config.BaseURL = env.GetOneWithFallback(EnvURL, defaultBaseURL, env.ParseString, altEnvName(EnvURL))
84 config.Username = values[EnvUsername]
85 config.Password = values[EnvPassword]
86 config.Zone = env.GetOneWithFallback(EnvZone, "", env.ParseString, altEnvName(EnvZone))
87
88 return NewDNSProviderConfig(config)
89 }
90
91 // NewDNSProviderConfig return a DNSProvider instance configured for Liquid Web.
92 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
93 if config == nil {
94 return nil, errors.New("liquidweb: the configuration of the DNS provider is nil")
95 }
96
97 if config.BaseURL == "" {
98 config.BaseURL = defaultBaseURL
99 }
100
101 client, err := lw.NewAPI(config.Username, config.Password, config.BaseURL, int(config.HTTPTimeout.Seconds()))
102 if err != nil {
103 return nil, fmt.Errorf("liquidweb: could not create Liquid Web API client: %w", err)
104 }
105
106 return &DNSProvider{
107 config: config,
108 recordIDs: make(map[string]int),
109 client: client,
110 }, nil
111 }
112
113 // Timeout returns the timeout and interval to use when checking for DNS propagation.
114 // Adjusting here to cope with spikes in propagation times.
115 func (d *DNSProvider) Timeout() (time.Duration, time.Duration) {
116 return d.config.PropagationTimeout, d.config.PollingInterval
117 }
118
119 // Present creates a TXT record using the specified parameters.
120 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
121 info := dns01.GetChallengeInfo(domain, keyAuth)
122
123 params := &network.DNSRecordParams{
124 Name: dns01.UnFqdn(info.EffectiveFQDN),
125 RData: strconv.Quote(info.Value),
126 Type: "TXT",
127 Zone: d.config.Zone,
128 TTL: d.config.TTL,
129 }
130
131 if params.Zone == "" {
132 bestZone, err := d.findZone(params.Name)
133 if err != nil {
134 return fmt.Errorf("liquidweb: %w", err)
135 }
136
137 params.Zone = bestZone
138 }
139
140 dnsEntry, err := d.client.NetworkDNS.Create(params)
141 if err != nil {
142 return fmt.Errorf("liquidweb: could not create TXT record: %w", err)
143 }
144
145 d.recordIDsMu.Lock()
146 d.recordIDs[token] = int(dnsEntry.ID)
147 d.recordIDsMu.Unlock()
148
149 return nil
150 }
151
152 // CleanUp removes the TXT record matching the specified parameters.
153 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
154 d.recordIDsMu.Lock()
155 recordID, ok := d.recordIDs[token]
156 d.recordIDsMu.Unlock()
157
158 if !ok {
159 return fmt.Errorf("liquidweb: unknown record ID for '%s'", domain)
160 }
161
162 params := &network.DNSRecordParams{ID: recordID}
163
164 _, err := d.client.NetworkDNS.Delete(params)
165 if err != nil {
166 return fmt.Errorf("liquidweb: could not remove TXT record: %w", err)
167 }
168
169 d.recordIDsMu.Lock()
170 delete(d.recordIDs, token)
171 d.recordIDsMu.Unlock()
172
173 return nil
174 }
175
176 func (d *DNSProvider) findZone(domain string) (string, error) {
177 zones, err := d.client.NetworkDNSZone.ListAll()
178 if err != nil {
179 return "", fmt.Errorf("failed to retrieve zones for account: %w", err)
180 }
181
182 // filter the zones on the account to only ones that match
183 var zs []network.DNSZone
184
185 for _, item := range zones.Items {
186 if strings.HasSuffix(domain, item.Name) {
187 zs = append(zs, item)
188 }
189 }
190
191 if len(zs) < 1 {
192 return "", fmt.Errorf("no valid zone in account for certificate '%s'", domain)
193 }
194
195 // powerdns _only_ looks for records on the longest matching subdomain zone aka,
196 // for test.sub.example.com if sub.example.com exists,
197 // it will look there it will not look atexample.com even if it also exists
198 sort.Slice(zs, func(i, j int) bool {
199 return len(zs[i].Name) > len(zs[j].Name)
200 })
201
202 return zs[0].Name, nil
203 }
204
205 func altEnvName(v string) string {
206 return strings.ReplaceAll(v, envNamespace, altEnvNamespace)
207 }
208