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