1// Package azure implements a DNS provider for solving the DNS-01 challenge using azure DNS. 2// Azure doesn't like trailing dots on domain names, most of the acme code does. 3package azure 4 5import ( 6 "context" 7 "errors" 8 "fmt" 9 "io/ioutil" 10 "net/http" 11 "strings" 12 "time" 13 14 "github.com/Azure/azure-sdk-for-go/services/dns/mgmt/2017-09-01/dns" 15 "github.com/Azure/go-autorest/autorest" 16 "github.com/Azure/go-autorest/autorest/azure/auth" 17 "github.com/Azure/go-autorest/autorest/to" 18 "github.com/go-acme/lego/v3/challenge/dns01" 19 "github.com/go-acme/lego/v3/platform/config/env" 20) 21 22const defaultMetadataEndpoint = "http://169.254.169.254" 23 24// Environment variables names. 25const ( 26 envNamespace = "AZURE_" 27 28 EnvMetadataEndpoint = envNamespace + "METADATA_ENDPOINT" 29 EnvSubscriptionID = envNamespace + "SUBSCRIPTION_ID" 30 EnvResourceGroup = envNamespace + "RESOURCE_GROUP" 31 EnvTenantID = envNamespace + "TENANT_ID" 32 EnvClientID = envNamespace + "CLIENT_ID" 33 EnvClientSecret = envNamespace + "CLIENT_SECRET" 34 35 EnvTTL = envNamespace + "TTL" 36 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" 37 EnvPollingInterval = envNamespace + "POLLING_INTERVAL" 38) 39 40// Config is used to configure the creation of the DNSProvider. 41type Config struct { 42 // optional if using instance metadata service 43 ClientID string 44 ClientSecret string 45 TenantID string 46 47 SubscriptionID string 48 ResourceGroup string 49 50 MetadataEndpoint string 51 52 PropagationTimeout time.Duration 53 PollingInterval time.Duration 54 TTL int 55 HTTPClient *http.Client 56} 57 58// NewDefaultConfig returns a default configuration for the DNSProvider. 59func NewDefaultConfig() *Config { 60 return &Config{ 61 TTL: env.GetOrDefaultInt(EnvTTL, 60), 62 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), 63 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 2*time.Second), 64 MetadataEndpoint: env.GetOrFile(EnvMetadataEndpoint), 65 } 66} 67 68// DNSProvider implements the challenge.Provider interface. 69type DNSProvider struct { 70 config *Config 71 authorizer autorest.Authorizer 72} 73 74// NewDNSProvider returns a DNSProvider instance configured for azure. 75// Credentials can be passed in the environment variables: 76// AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP 77// If the credentials are _not_ set via the environment, 78// then it will attempt to get a bearer token via the instance metadata service. 79// see: https://github.com/Azure/go-autorest/blob/v10.14.0/autorest/azure/auth/auth.go#L38-L42 80func NewDNSProvider() (*DNSProvider, error) { 81 config := NewDefaultConfig() 82 config.SubscriptionID = env.GetOrFile(EnvSubscriptionID) 83 config.ResourceGroup = env.GetOrFile(EnvResourceGroup) 84 config.ClientSecret = env.GetOrFile(EnvClientSecret) 85 config.ClientID = env.GetOrFile(EnvClientID) 86 config.TenantID = env.GetOrFile(EnvTenantID) 87 88 return NewDNSProviderConfig(config) 89} 90 91// NewDNSProviderConfig return a DNSProvider instance configured for Azure. 92func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { 93 if config == nil { 94 return nil, errors.New("azure: the configuration of the DNS provider is nil") 95 } 96 97 if config.HTTPClient == nil { 98 config.HTTPClient = http.DefaultClient 99 } 100 101 authorizer, err := getAuthorizer(config) 102 if err != nil { 103 return nil, err 104 } 105 106 if config.SubscriptionID == "" { 107 subsID, err := getMetadata(config, "subscriptionId") 108 if err != nil { 109 return nil, fmt.Errorf("azure: %w", err) 110 } 111 112 if subsID == "" { 113 return nil, errors.New("azure: SubscriptionID is missing") 114 } 115 config.SubscriptionID = subsID 116 } 117 118 if config.ResourceGroup == "" { 119 resGroup, err := getMetadata(config, "resourceGroupName") 120 if err != nil { 121 return nil, fmt.Errorf("azure: %w", err) 122 } 123 124 if resGroup == "" { 125 return nil, errors.New("azure: ResourceGroup is missing") 126 } 127 config.ResourceGroup = resGroup 128 } 129 130 return &DNSProvider{config: config, authorizer: authorizer}, nil 131} 132 133// Timeout returns the timeout and interval to use when checking for DNS propagation. 134// Adjusting here to cope with spikes in propagation times. 135func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { 136 return d.config.PropagationTimeout, d.config.PollingInterval 137} 138 139// Present creates a TXT record to fulfill the dns-01 challenge. 140func (d *DNSProvider) Present(domain, token, keyAuth string) error { 141 ctx := context.Background() 142 fqdn, value := dns01.GetRecord(domain, keyAuth) 143 144 zone, err := d.getHostedZoneID(ctx, fqdn) 145 if err != nil { 146 return fmt.Errorf("azure: %w", err) 147 } 148 149 rsc := dns.NewRecordSetsClient(d.config.SubscriptionID) 150 rsc.Authorizer = d.authorizer 151 152 relative := toRelativeRecord(fqdn, dns01.ToFqdn(zone)) 153 154 // Get existing record set 155 rset, err := rsc.Get(ctx, d.config.ResourceGroup, zone, relative, dns.TXT) 156 if err != nil { 157 detailedError, ok := err.(autorest.DetailedError) 158 if !ok || detailedError.StatusCode != http.StatusNotFound { 159 return fmt.Errorf("azure: %w", err) 160 } 161 } 162 163 // Construct unique TXT records using map 164 uniqRecords := map[string]struct{}{value: {}} 165 if rset.RecordSetProperties != nil && rset.TxtRecords != nil { 166 for _, txtRecord := range *rset.TxtRecords { 167 // Assume Value doesn't contain multiple strings 168 if txtRecord.Value != nil && len(*txtRecord.Value) > 0 { 169 uniqRecords[(*txtRecord.Value)[0]] = struct{}{} 170 } 171 } 172 } 173 174 var txtRecords []dns.TxtRecord 175 for txt := range uniqRecords { 176 txtRecords = append(txtRecords, dns.TxtRecord{Value: &[]string{txt}}) 177 } 178 179 rec := dns.RecordSet{ 180 Name: &relative, 181 RecordSetProperties: &dns.RecordSetProperties{ 182 TTL: to.Int64Ptr(int64(d.config.TTL)), 183 TxtRecords: &txtRecords, 184 }, 185 } 186 187 _, err = rsc.CreateOrUpdate(ctx, d.config.ResourceGroup, zone, relative, dns.TXT, rec, "", "") 188 if err != nil { 189 return fmt.Errorf("azure: %w", err) 190 } 191 return nil 192} 193 194// CleanUp removes the TXT record matching the specified parameters. 195func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { 196 ctx := context.Background() 197 fqdn, _ := dns01.GetRecord(domain, keyAuth) 198 199 zone, err := d.getHostedZoneID(ctx, fqdn) 200 if err != nil { 201 return fmt.Errorf("azure: %w", err) 202 } 203 204 relative := toRelativeRecord(fqdn, dns01.ToFqdn(zone)) 205 rsc := dns.NewRecordSetsClient(d.config.SubscriptionID) 206 rsc.Authorizer = d.authorizer 207 208 _, err = rsc.Delete(ctx, d.config.ResourceGroup, zone, relative, dns.TXT, "") 209 if err != nil { 210 return fmt.Errorf("azure: %w", err) 211 } 212 return nil 213} 214 215// Checks that azure has a zone for this domain name. 216func (d *DNSProvider) getHostedZoneID(ctx context.Context, fqdn string) (string, error) { 217 authZone, err := dns01.FindZoneByFqdn(fqdn) 218 if err != nil { 219 return "", err 220 } 221 222 dc := dns.NewZonesClient(d.config.SubscriptionID) 223 dc.Authorizer = d.authorizer 224 225 zone, err := dc.Get(ctx, d.config.ResourceGroup, dns01.UnFqdn(authZone)) 226 if err != nil { 227 return "", err 228 } 229 230 // zone.Name shouldn't have a trailing dot(.) 231 return to.String(zone.Name), nil 232} 233 234// Returns the relative record to the domain. 235func toRelativeRecord(domain, zone string) string { 236 return dns01.UnFqdn(strings.TrimSuffix(domain, zone)) 237} 238 239func getAuthorizer(config *Config) (autorest.Authorizer, error) { 240 if config.ClientID != "" && config.ClientSecret != "" && config.TenantID != "" { 241 credentialsConfig := auth.NewClientCredentialsConfig(config.ClientID, config.ClientSecret, config.TenantID) 242 243 spToken, err := credentialsConfig.ServicePrincipalToken() 244 if err != nil { 245 return nil, fmt.Errorf("failed to get oauth token from client credentials: %v", err) 246 } 247 248 spToken.SetSender(config.HTTPClient) 249 250 return autorest.NewBearerAuthorizer(spToken), nil 251 } 252 return auth.NewAuthorizerFromEnvironment() 253} 254 255// Fetches metadata from environment or he instance metadata service. 256// borrowed from https://github.com/Microsoft/azureimds/blob/master/imdssample.go 257func getMetadata(config *Config, field string) (string, error) { 258 metadataEndpoint := config.MetadataEndpoint 259 if len(metadataEndpoint) == 0 { 260 metadataEndpoint = defaultMetadataEndpoint 261 } 262 263 resource := fmt.Sprintf("%s/metadata/instance/compute/%s", metadataEndpoint, field) 264 req, err := http.NewRequest(http.MethodGet, resource, nil) 265 if err != nil { 266 return "", err 267 } 268 269 req.Header.Add("Metadata", "True") 270 271 q := req.URL.Query() 272 q.Add("format", "text") 273 q.Add("api-version", "2017-12-01") 274 req.URL.RawQuery = q.Encode() 275 276 resp, err := config.HTTPClient.Do(req) 277 if err != nil { 278 return "", err 279 } 280 defer resp.Body.Close() 281 282 respBody, err := ioutil.ReadAll(resp.Body) 283 if err != nil { 284 return "", err 285 } 286 287 return string(respBody), nil 288} 289