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