hyperone.go raw

   1  // Package hyperone implements a DNS provider for solving the DNS-01 challenge using HyperOne.
   2  package hyperone
   3  
   4  import (
   5  	"context"
   6  	"fmt"
   7  	"net/http"
   8  	"os"
   9  	"path/filepath"
  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/hyperone/internal"
  16  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  17  )
  18  
  19  // Environment variables names.
  20  const (
  21  	envNamespace = "HYPERONE_"
  22  
  23  	EnvPassportLocation = envNamespace + "PASSPORT_LOCATION"
  24  	EnvAPIUrl           = envNamespace + "API_URL"
  25  	EnvLocationID       = envNamespace + "LOCATION_ID"
  26  
  27  	EnvTTL                = envNamespace + "TTL"
  28  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  29  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  30  	EnvHTTPTimeout        = envNamespace + "HTTP_TIMEOUT"
  31  )
  32  
  33  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  34  
  35  // Config is used to configure the creation of the DNSProvider.
  36  type Config struct {
  37  	APIEndpoint      string
  38  	LocationID       string
  39  	PassportLocation string
  40  
  41  	TTL                int
  42  	PropagationTimeout time.Duration
  43  	PollingInterval    time.Duration
  44  	HTTPClient         *http.Client
  45  }
  46  
  47  // NewDefaultConfig returns a default configuration for the DNSProvider.
  48  func NewDefaultConfig() *Config {
  49  	return &Config{
  50  		TTL:                env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
  51  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  52  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  53  		HTTPClient: &http.Client{
  54  			Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
  55  		},
  56  	}
  57  }
  58  
  59  // DNSProvider implements the challenge.Provider interface.
  60  type DNSProvider struct {
  61  	client *internal.Client
  62  	config *Config
  63  }
  64  
  65  // NewDNSProvider returns a DNSProvider instance configured for HyperOne.
  66  func NewDNSProvider() (*DNSProvider, error) {
  67  	config := NewDefaultConfig()
  68  
  69  	config.PassportLocation = env.GetOrFile(EnvPassportLocation)
  70  	config.LocationID = env.GetOrFile(EnvLocationID)
  71  	config.APIEndpoint = env.GetOrFile(EnvAPIUrl)
  72  
  73  	return NewDNSProviderConfig(config)
  74  }
  75  
  76  // NewDNSProviderConfig return a DNSProvider instance configured for HyperOne.
  77  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  78  	if config.PassportLocation == "" {
  79  		var err error
  80  
  81  		config.PassportLocation, err = GetDefaultPassportLocation()
  82  		if err != nil {
  83  			return nil, fmt.Errorf("hyperone: %w", err)
  84  		}
  85  	}
  86  
  87  	passport, err := internal.LoadPassportFile(config.PassportLocation)
  88  	if err != nil {
  89  		return nil, fmt.Errorf("hyperone: %w", err)
  90  	}
  91  
  92  	client, err := internal.NewClient(config.APIEndpoint, config.LocationID, passport)
  93  	if err != nil {
  94  		return nil, fmt.Errorf("hyperone: failed to create client: %w", err)
  95  	}
  96  
  97  	if config.HTTPClient != nil {
  98  		client.HTTPClient = config.HTTPClient
  99  	}
 100  
 101  	client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
 102  
 103  	return &DNSProvider{client: client, config: config}, nil
 104  }
 105  
 106  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 107  // Adjusting here to cope with spikes in propagation times.
 108  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 109  	return d.config.PropagationTimeout, d.config.PollingInterval
 110  }
 111  
 112  // Present creates a TXT record to fulfill the dns-01 challenge.
 113  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 114  	info := dns01.GetChallengeInfo(domain, keyAuth)
 115  
 116  	ctx := context.Background()
 117  
 118  	zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
 119  	if err != nil {
 120  		return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", info.EffectiveFQDN, err)
 121  	}
 122  
 123  	recordset, err := d.client.FindRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN)
 124  	if err != nil {
 125  		return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", info.EffectiveFQDN, zone.ID, err)
 126  	}
 127  
 128  	if recordset == nil {
 129  		_, err = d.client.CreateRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN, info.Value, d.config.TTL)
 130  		if err != nil {
 131  			return fmt.Errorf("hyperone: failed to create recordset: fqdn=%s, zone ID=%s, value=%s: %w", info.EffectiveFQDN, zone.ID, info.Value, err)
 132  		}
 133  
 134  		return nil
 135  	}
 136  
 137  	_, err = d.client.CreateRecord(ctx, zone.ID, recordset.ID, info.Value)
 138  	if err != nil {
 139  		return fmt.Errorf("hyperone: failed to create record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, err)
 140  	}
 141  
 142  	return nil
 143  }
 144  
 145  // CleanUp removes the TXT record matching the specified parameters and recordset if no other records are remaining.
 146  // There is a small possibility that race will cause to delete recordset with records for other DNS Challenges.
 147  func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 148  	info := dns01.GetChallengeInfo(domain, keyAuth)
 149  
 150  	ctx := context.Background()
 151  
 152  	zone, err := d.getHostedZone(ctx, info.EffectiveFQDN)
 153  	if err != nil {
 154  		return fmt.Errorf("hyperone: failed to get zone for fqdn=%s: %w", info.EffectiveFQDN, err)
 155  	}
 156  
 157  	recordset, err := d.client.FindRecordset(ctx, zone.ID, "TXT", info.EffectiveFQDN)
 158  	if err != nil {
 159  		return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s: %w", info.EffectiveFQDN, zone.ID, err)
 160  	}
 161  
 162  	if recordset == nil {
 163  		return fmt.Errorf("hyperone: recordset to remove not found: fqdn=%s", info.EffectiveFQDN)
 164  	}
 165  
 166  	records, err := d.client.GetRecords(ctx, zone.ID, recordset.ID)
 167  	if err != nil {
 168  		return fmt.Errorf("hyperone: %w", err)
 169  	}
 170  
 171  	if len(records) == 1 {
 172  		if records[0].Content != info.Value {
 173  			return fmt.Errorf("hyperone: record with content %s not found: fqdn=%s", info.Value, info.EffectiveFQDN)
 174  		}
 175  
 176  		err = d.client.DeleteRecordset(ctx, zone.ID, recordset.ID)
 177  		if err != nil {
 178  			return fmt.Errorf("hyperone: failed to delete record: fqdn=%s, zone ID=%s, recordset ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, err)
 179  		}
 180  
 181  		return nil
 182  	}
 183  
 184  	for _, record := range records {
 185  		if record.Content == info.Value {
 186  			err = d.client.DeleteRecord(ctx, zone.ID, recordset.ID, record.ID)
 187  			if err != nil {
 188  				return fmt.Errorf("hyperone: fqdn=%s, zone ID=%s, recordset ID=%s, record ID=%s: %w", info.EffectiveFQDN, zone.ID, recordset.ID, record.ID, err)
 189  			}
 190  
 191  			return nil
 192  		}
 193  	}
 194  
 195  	return fmt.Errorf("hyperone: fqdn=%s, failed to find record with given value", info.EffectiveFQDN)
 196  }
 197  
 198  // getHostedZone gets the hosted zone.
 199  func (d *DNSProvider) getHostedZone(ctx context.Context, fqdn string) (*internal.Zone, error) {
 200  	authZone, err := dns01.FindZoneByFqdn(fqdn)
 201  	if err != nil {
 202  		return nil, fmt.Errorf("could not find zone: %w", err)
 203  	}
 204  
 205  	return d.client.FindZone(ctx, authZone)
 206  }
 207  
 208  func GetDefaultPassportLocation() (string, error) {
 209  	homeDir, err := os.UserHomeDir()
 210  	if err != nil {
 211  		return "", fmt.Errorf("failed to get user home directory: %w", err)
 212  	}
 213  
 214  	return filepath.Join(homeDir, ".h1", "passport.json"), nil
 215  }
 216