iij.go raw
1 // Package iij implements a DNS provider for solving the DNS-01 challenge using IIJ DNS.
2 package iij
3
4 import (
5 "errors"
6 "fmt"
7 "slices"
8 "strconv"
9 "time"
10
11 "github.com/go-acme/lego/v4/challenge"
12 "github.com/go-acme/lego/v4/challenge/dns01"
13 "github.com/go-acme/lego/v4/platform/config/env"
14 "github.com/iij/doapi"
15 "github.com/iij/doapi/protocol"
16 "github.com/miekg/dns"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "IIJ_"
22
23 EnvAPIAccessKey = envNamespace + "API_ACCESS_KEY"
24 EnvAPISecretKey = envNamespace + "API_SECRET_KEY"
25 EnvDoServiceCode = envNamespace + "DO_SERVICE_CODE"
26
27 EnvTTL = envNamespace + "TTL"
28 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
29 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
30 )
31
32 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
33
34 // Config is used to configure the creation of the DNSProvider.
35 type Config struct {
36 AccessKey string
37 SecretKey string
38 DoServiceCode string
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 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
49 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second),
50 }
51 }
52
53 // DNSProvider implements the challenge.Provider interface.
54 type DNSProvider struct {
55 api *doapi.API
56 config *Config
57 }
58
59 // NewDNSProvider returns a DNSProvider instance configured for IIJ DNS.
60 func NewDNSProvider() (*DNSProvider, error) {
61 values, err := env.Get(EnvAPIAccessKey, EnvAPISecretKey, EnvDoServiceCode)
62 if err != nil {
63 return nil, fmt.Errorf("iij: %w", err)
64 }
65
66 config := NewDefaultConfig()
67 config.AccessKey = values[EnvAPIAccessKey]
68 config.SecretKey = values[EnvAPISecretKey]
69 config.DoServiceCode = values[EnvDoServiceCode]
70
71 return NewDNSProviderConfig(config)
72 }
73
74 // NewDNSProviderConfig takes a given config
75 // and returns a custom configured DNSProvider instance.
76 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
77 if config.SecretKey == "" || config.AccessKey == "" || config.DoServiceCode == "" {
78 return nil, errors.New("iij: credentials missing")
79 }
80
81 return &DNSProvider{
82 api: doapi.NewAPI(config.AccessKey, config.SecretKey),
83 config: config,
84 }, nil
85 }
86
87 // Timeout returns the timeout and interval to use when checking for DNS propagation.
88 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
89 return d.config.PropagationTimeout, d.config.PollingInterval
90 }
91
92 // Present creates a TXT record using the specified parameters.
93 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
94 info := dns01.GetChallengeInfo(domain, keyAuth)
95
96 // TODO(ldez) replace domain by FQDN to follow CNAME.
97 err := d.addTxtRecord(domain, info.Value)
98 if err != nil {
99 return fmt.Errorf("iij: %w", err)
100 }
101
102 return nil
103 }
104
105 // CleanUp removes the TXT record matching the specified parameters.
106 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
107 info := dns01.GetChallengeInfo(domain, keyAuth)
108
109 // TODO(ldez) replace domain by FQDN to follow CNAME.
110 err := d.deleteTxtRecord(domain, info.Value)
111 if err != nil {
112 return fmt.Errorf("iij: %w", err)
113 }
114
115 return nil
116 }
117
118 func (d *DNSProvider) addTxtRecord(domain, value string) error {
119 zones, err := d.listZones()
120 if err != nil {
121 return err
122 }
123
124 // TODO(ldez) replace domain by FQDN to follow CNAME.
125 owner, zone, err := splitDomain(domain, zones)
126 if err != nil {
127 return err
128 }
129
130 request := protocol.RecordAdd{
131 DoServiceCode: d.config.DoServiceCode,
132 ZoneName: zone,
133 Owner: owner,
134 TTL: strconv.Itoa(d.config.TTL),
135 RecordType: "TXT",
136 RData: value,
137 }
138
139 response := &protocol.RecordAddResponse{}
140
141 if err := doapi.Call(*d.api, request, response); err != nil {
142 return err
143 }
144
145 return d.commit()
146 }
147
148 func (d *DNSProvider) deleteTxtRecord(domain, value string) error {
149 zones, err := d.listZones()
150 if err != nil {
151 return err
152 }
153
154 owner, zone, err := splitDomain(domain, zones)
155 if err != nil {
156 return err
157 }
158
159 id, err := d.findTxtRecord(owner, zone, value)
160 if err != nil {
161 return err
162 }
163
164 request := protocol.RecordDelete{
165 DoServiceCode: d.config.DoServiceCode,
166 ZoneName: zone,
167 RecordID: id,
168 }
169
170 response := &protocol.RecordDeleteResponse{}
171
172 if err := doapi.Call(*d.api, request, response); err != nil {
173 return err
174 }
175
176 return d.commit()
177 }
178
179 func (d *DNSProvider) commit() error {
180 request := protocol.Commit{
181 DoServiceCode: d.config.DoServiceCode,
182 }
183
184 response := &protocol.CommitResponse{}
185
186 return doapi.Call(*d.api, request, response)
187 }
188
189 func (d *DNSProvider) findTxtRecord(owner, zone, value string) (string, error) {
190 request := protocol.RecordListGet{
191 DoServiceCode: d.config.DoServiceCode,
192 ZoneName: zone,
193 }
194
195 response := &protocol.RecordListGetResponse{}
196
197 if err := doapi.Call(*d.api, request, response); err != nil {
198 return "", err
199 }
200
201 var id string
202
203 for _, record := range response.RecordList {
204 if record.Owner == owner && record.RecordType == "TXT" && record.RData == "\""+value+"\"" {
205 id = record.Id
206 }
207 }
208
209 if id == "" {
210 return "", fmt.Errorf("%s record in %s not found", owner, zone)
211 }
212
213 return id, nil
214 }
215
216 func (d *DNSProvider) listZones() ([]string, error) {
217 request := protocol.ZoneListGet{
218 DoServiceCode: d.config.DoServiceCode,
219 }
220
221 response := &protocol.ZoneListGetResponse{}
222
223 if err := doapi.Call(*d.api, request, response); err != nil {
224 return nil, err
225 }
226
227 return response.ZoneList, nil
228 }
229
230 func splitDomain(domain string, zones []string) (string, string, error) {
231 base := dns01.UnFqdn(domain)
232
233 for _, index := range dns.Split(base) {
234 zone := base[index:]
235
236 if slices.Contains(zones, zone) {
237 baseOwner := base[:index]
238 if baseOwner != "" {
239 baseOwner = "." + baseOwner
240 }
241
242 return "_acme-challenge" + dns01.UnFqdn(baseOwner), zone, nil
243 }
244 }
245
246 return "", "", fmt.Errorf("%s not found", domain)
247 }
248