Fix: acme DNS providers

This commit is contained in:
Ludovic Fernandez 2018-10-11 16:50:04 +02:00 committed by Traefiker Bot
parent 1b54f4d32a
commit 7eeac63139
11 changed files with 488 additions and 5 deletions

4
Gopkg.lock generated
View file

@ -1393,10 +1393,11 @@
"providers/dns/rfc2136",
"providers/dns/route53",
"providers/dns/sakuracloud",
"providers/dns/stackpath",
"providers/dns/vegadns",
"providers/dns/vultr"
]
revision = "dd087560a0a4a52b3388dd320a5982a0e8233eff"
revision = "01c63ec08d1d85e3ad44c16dff95dadee26a81bc"
[[projects]]
branch = "master"
@ -1442,6 +1443,7 @@
name = "golang.org/x/oauth2"
packages = [
".",
"clientcredentials",
"google",
"internal",
"jws",

View file

@ -291,6 +291,7 @@ Here is a list of supported `provider`s, that can automate the DNS verification,
| [RFC2136](https://tools.ietf.org/html/rfc2136) | `rfc2136` | `RFC2136_TSIG_KEY`, `RFC2136_TSIG_SECRET`, `RFC2136_TSIG_ALGORITHM`, `RFC2136_NAMESERVER` | Not tested yet |
| [Route 53](https://aws.amazon.com/route53/) | `route53` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `[AWS_REGION]`, `[AWS_HOSTED_ZONE_ID]` or a configured user/instance IAM profile. | YES |
| [Sakura Cloud](https://cloud.sakura.ad.jp/) | `sakuracloud` | `SAKURACLOUD_ACCESS_TOKEN`, `SAKURACLOUD_ACCESS_TOKEN_SECRET` | Not tested yet |
| [Stackpath](https://www.stackpath.com/) | `stackpath` | `STACKPATH_CLIENT_ID`, `STACKPATH_CLIENT_SECRET`, `STACKPATH_STACK_ID` | Not tested yet |
| [VegaDNS](https://github.com/shupp/VegaDNS-API) | `vegadns` | `SECRET_VEGADNS_KEY`, `SECRET_VEGADNS_SECRET`, `VEGADNS_URL` | Not tested yet |
| [VULTR](https://www.vultr.com) | `vultr` | `VULTR_API_KEY` | Not tested yet |

View file

@ -61,7 +61,7 @@ type DNSProvider struct {
// The REST endpoint will be appended. In addition, the Configuration name
// and external DNS View Name must be passed in BLUECAT_CONFIG_NAME and BLUECAT_DNS_VIEW
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("BLUECAT_SERVER_URL", "BLUECAT_USER_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_CONFIG_NAME", "BLUECAT_DNS_VIEW")
values, err := env.Get("BLUECAT_SERVER_URL", "BLUECAT_USER_NAME", "BLUECAT_PASSWORD", "BLUECAT_CONFIG_NAME", "BLUECAT_DNS_VIEW")
if err != nil {
return nil, fmt.Errorf("bluecat: %v", err)
}

View file

@ -76,7 +76,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client.HTTPClient = config.HTTPClient
return &DNSProvider{client: client}, nil
return &DNSProvider{client: client, config: config}, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.

View file

@ -42,6 +42,7 @@ import (
"github.com/xenolf/lego/providers/dns/rfc2136"
"github.com/xenolf/lego/providers/dns/route53"
"github.com/xenolf/lego/providers/dns/sakuracloud"
"github.com/xenolf/lego/providers/dns/stackpath"
"github.com/xenolf/lego/providers/dns/vegadns"
"github.com/xenolf/lego/providers/dns/vultr"
)
@ -127,6 +128,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
return rfc2136.NewDNSProvider()
case "sakuracloud":
return sakuracloud.NewDNSProvider()
case "stackpath":
return stackpath.NewDNSProvider()
case "vegadns":
return vegadns.NewDNSProvider()
case "vultr":

View file

@ -81,7 +81,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := dnspod.NewClient(params)
client.HttpClient = config.HTTPClient
return &DNSProvider{client: client}, nil
return &DNSProvider{client: client, config: config}, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.

View file

@ -94,6 +94,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
}
return &DNSProvider{
config: config,
activeRecords: make(map[string]int),
}, nil
}

View file

@ -82,7 +82,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
client := api.NewClient(config.Token, config.Secret, "tk1a")
client.UserAgent = acme.UserAgent
return &DNSProvider{client: client}, nil
return &DNSProvider{client: client, config: config}, nil
}
// Present creates a TXT record to fulfill the dns-01 challenge.

View file

@ -0,0 +1,217 @@
package stackpath
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"path"
"github.com/xenolf/lego/acme"
"golang.org/x/net/publicsuffix"
)
// Zones is the response struct from the Stackpath api GetZones
type Zones struct {
Zones []Zone `json:"zones"`
}
// Zone a DNS zone representation
type Zone struct {
ID string
Domain string
}
// Records is the response struct from the Stackpath api GetZoneRecords
type Records struct {
Records []Record `json:"records"`
}
// Record a DNS record representation
type Record struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
Data string `json:"data"`
}
// ErrorResponse the API error response representation
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"error"`
}
func (e *ErrorResponse) Error() string {
return fmt.Sprintf("%d %s", e.Code, e.Message)
}
// https://developer.stackpath.com/en/api/dns/#operation/GetZones
func (d *DNSProvider) getZones(domain string) (*Zone, error) {
domain = acme.UnFqdn(domain)
tld, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
return nil, err
}
req, err := d.newRequest(http.MethodGet, "/zones", nil)
if err != nil {
return nil, err
}
query := req.URL.Query()
query.Add("page_request.filter", fmt.Sprintf("domain='%s'", tld))
req.URL.RawQuery = query.Encode()
var zones Zones
err = d.do(req, &zones)
if err != nil {
return nil, err
}
if len(zones.Zones) == 0 {
return nil, fmt.Errorf("did not find zone with domain %s", domain)
}
return &zones.Zones[0], nil
}
// https://developer.stackpath.com/en/api/dns/#operation/GetZoneRecords
func (d *DNSProvider) getZoneRecords(name string, zone *Zone) ([]Record, error) {
u := fmt.Sprintf("/zones/%s/records", zone.ID)
req, err := d.newRequest(http.MethodGet, u, nil)
if err != nil {
return nil, err
}
query := req.URL.Query()
query.Add("page_request.filter", fmt.Sprintf("name='%s' and type='TXT'", name))
req.URL.RawQuery = query.Encode()
var records Records
err = d.do(req, &records)
if err != nil {
return nil, err
}
if len(records.Records) == 0 {
return nil, fmt.Errorf("did not find record with name %s", name)
}
return records.Records, nil
}
// https://developer.stackpath.com/en/api/dns/#operation/CreateZoneRecord
func (d *DNSProvider) createZoneRecord(zone *Zone, record Record) error {
u := fmt.Sprintf("/zones/%s/records", zone.ID)
req, err := d.newRequest(http.MethodPost, u, record)
if err != nil {
return err
}
return d.do(req, nil)
}
// https://developer.stackpath.com/en/api/dns/#operation/DeleteZoneRecord
func (d *DNSProvider) deleteZoneRecord(zone *Zone, record Record) error {
u := fmt.Sprintf("/zones/%s/records/%s", zone.ID, record.ID)
req, err := d.newRequest(http.MethodDelete, u, nil)
if err != nil {
return err
}
return d.do(req, nil)
}
func (d *DNSProvider) newRequest(method, urlStr string, body interface{}) (*http.Request, error) {
u, err := d.BaseURL.Parse(path.Join(d.config.StackID, urlStr))
if err != nil {
return nil, err
}
if body == nil {
var req *http.Request
req, err = http.NewRequest(method, u.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
reqBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, u.String(), bytes.NewBuffer(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
func (d *DNSProvider) do(req *http.Request, v interface{}) error {
resp, err := d.client.Do(req)
if err != nil {
return err
}
err = checkResponse(resp)
if err != nil {
return err
}
if v == nil {
return nil
}
raw, err := readBody(resp)
if err != nil {
return fmt.Errorf("failed to read body: %v", err)
}
err = json.Unmarshal(raw, v)
if err != nil {
return fmt.Errorf("unmarshaling error: %v: %s", err, string(raw))
}
return nil
}
func checkResponse(resp *http.Response) error {
if resp.StatusCode > 299 {
data, err := readBody(resp)
if err != nil {
return &ErrorResponse{Code: resp.StatusCode, Message: err.Error()}
}
errResp := &ErrorResponse{}
err = json.Unmarshal(data, errResp)
if err != nil {
return &ErrorResponse{Code: resp.StatusCode, Message: fmt.Sprintf("unmarshaling error: %v: %s", err, string(data))}
}
return errResp
}
return nil
}
func readBody(resp *http.Response) ([]byte, error) {
if resp.Body == nil {
return nil, fmt.Errorf("response body is nil")
}
defer resp.Body.Close()
rawBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return rawBody, nil
}

View file

@ -0,0 +1,150 @@
// Package stackpath implements a DNS provider for solving the DNS-01 challenge using Stackpath DNS.
// https://developer.stackpath.com/en/api/dns/
package stackpath
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/xenolf/lego/acme"
"github.com/xenolf/lego/platform/config/env"
"golang.org/x/oauth2/clientcredentials"
)
const (
defaultBaseURL = "https://gateway.stackpath.com/dns/v1/stacks/"
defaultAuthURL = "https://gateway.stackpath.com/identity/v1/oauth2/token"
)
// Config is used to configure the creation of the DNSProvider
type Config struct {
ClientID string
ClientSecret string
StackID string
TTL int
PropagationTimeout time.Duration
PollingInterval time.Duration
}
// NewDefaultConfig returns a default configuration for the DNSProvider
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt("STACKPATH_TTL", 120),
PropagationTimeout: env.GetOrDefaultSecond("STACKPATH_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout),
PollingInterval: env.GetOrDefaultSecond("STACKPATH_POLLING_INTERVAL", acme.DefaultPollingInterval),
}
}
// DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct {
BaseURL *url.URL
client *http.Client
config *Config
}
// NewDNSProvider returns a DNSProvider instance configured for Stackpath.
// Credentials must be passed in the environment variables:
// STACKPATH_CLIENT_ID, STACKPATH_CLIENT_SECRET, and STACKPATH_STACK_ID.
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get("STACKPATH_CLIENT_ID", "STACKPATH_CLIENT_SECRET", "STACKPATH_STACK_ID")
if err != nil {
return nil, fmt.Errorf("stackpath: %v", err)
}
config := NewDefaultConfig()
config.ClientID = values["STACKPATH_CLIENT_ID"]
config.ClientSecret = values["STACKPATH_CLIENT_SECRET"]
config.StackID = values["STACKPATH_STACK_ID"]
return NewDNSProviderConfig(config)
}
// NewDNSProviderConfig return a DNSProvider instance configured for Stackpath.
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("stackpath: the configuration of the DNS provider is nil")
}
if len(config.ClientID) == 0 || len(config.ClientSecret) == 0 {
return nil, errors.New("stackpath: credentials missing")
}
if len(config.StackID) == 0 {
return nil, errors.New("stackpath: stack id missing")
}
baseURL, _ := url.Parse(defaultBaseURL)
return &DNSProvider{
BaseURL: baseURL,
client: getOathClient(config),
config: config,
}, nil
}
func getOathClient(config *Config) *http.Client {
oathConfig := &clientcredentials.Config{
TokenURL: defaultAuthURL,
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
}
return oathConfig.Client(context.Background())
}
// Present creates a TXT record to fulfill the dns-01 challenge
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
zone, err := d.getZones(domain)
if err != nil {
return fmt.Errorf("stackpath: %v", err)
}
fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
parts := strings.Split(fqdn, ".")
record := Record{
Name: parts[0],
Type: "TXT",
TTL: d.config.TTL,
Data: value,
}
return d.createZoneRecord(zone, record)
}
// CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
zone, err := d.getZones(domain)
if err != nil {
return fmt.Errorf("stackpath: %v", err)
}
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
parts := strings.Split(fqdn, ".")
records, err := d.getZoneRecords(parts[0], zone)
if err != nil {
return err
}
for _, record := range records {
err = d.deleteZoneRecord(zone, record)
if err != nil {
log.Printf("stackpath: failed to delete TXT record: %v", err)
}
}
return nil
}
// Timeout returns the timeout and interval to use when checking for DNS propagation.
// Adjusting here to cope with spikes in propagation times.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}

View file

@ -0,0 +1,109 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package clientcredentials implements the OAuth2.0 "client credentials" token flow,
// also known as the "two-legged OAuth 2.0".
//
// This should be used when the client is acting on its own behalf or when the client
// is the resource owner. It may also be used when requesting access to protected
// resources based on an authorization previously arranged with the authorization
// server.
//
// See https://tools.ietf.org/html/rfc6749#section-4.4
package clientcredentials // import "golang.org/x/oauth2/clientcredentials"
import (
"fmt"
"net/http"
"net/url"
"strings"
"golang.org/x/net/context"
"golang.org/x/oauth2"
"golang.org/x/oauth2/internal"
)
// Config describes a 2-legged OAuth2 flow, with both the
// client application information and the server's endpoint URLs.
type Config struct {
// ClientID is the application's ID.
ClientID string
// ClientSecret is the application's secret.
ClientSecret string
// TokenURL is the resource server's token endpoint
// URL. This is a constant specific to each server.
TokenURL string
// Scope specifies optional requested permissions.
Scopes []string
// EndpointParams specifies additional parameters for requests to the token endpoint.
EndpointParams url.Values
}
// Token uses client credentials to retrieve a token.
// The HTTP client to use is derived from the context.
// If nil, http.DefaultClient is used.
func (c *Config) Token(ctx context.Context) (*oauth2.Token, error) {
return c.TokenSource(ctx).Token()
}
// Client returns an HTTP client using the provided token.
// The token will auto-refresh as necessary. The underlying
// HTTP transport will be obtained using the provided context.
// The returned client and its Transport should not be modified.
func (c *Config) Client(ctx context.Context) *http.Client {
return oauth2.NewClient(ctx, c.TokenSource(ctx))
}
// TokenSource returns a TokenSource that returns t until t expires,
// automatically refreshing it as necessary using the provided context and the
// client ID and client secret.
//
// Most users will use Config.Client instead.
func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
source := &tokenSource{
ctx: ctx,
conf: c,
}
return oauth2.ReuseTokenSource(nil, source)
}
type tokenSource struct {
ctx context.Context
conf *Config
}
// Token refreshes the token by using a new client credentials request.
// tokens received this way do not include a refresh token
func (c *tokenSource) Token() (*oauth2.Token, error) {
v := url.Values{
"grant_type": {"client_credentials"},
}
if len(c.conf.Scopes) > 0 {
v.Set("scope", strings.Join(c.conf.Scopes, " "))
}
for k, p := range c.conf.EndpointParams {
if _, ok := v[k]; ok {
return nil, fmt.Errorf("oauth2: cannot overwrite parameter %q", k)
}
v[k] = p
}
tk, err := internal.RetrieveToken(c.ctx, c.conf.ClientID, c.conf.ClientSecret, c.conf.TokenURL, v)
if err != nil {
if rErr, ok := err.(*internal.RetrieveError); ok {
return nil, (*oauth2.RetrieveError)(rErr)
}
return nil, err
}
t := &oauth2.Token{
AccessToken: tk.AccessToken,
TokenType: tk.TokenType,
RefreshToken: tk.RefreshToken,
Expiry: tk.Expiry,
}
return t.WithExtra(tk.Raw), nil
}