Merge branch 'master' into acmev2

# Conflicts:
#	providers/dns/azure/azure.go
This commit is contained in:
Matthew Holt 2018-05-03 14:44:33 -06:00
commit ef5b5bffb6
24 changed files with 1760 additions and 13 deletions

6
cli.go
View file

@ -199,18 +199,23 @@ Here is an example bash command using the CloudFlare DNS provider:
fmt.Fprintln(w)
fmt.Fprintln(w, "\tazure:\tAZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_TENANT_ID, AZURE_RESOURCE_GROUP")
fmt.Fprintln(w, "\tauroradns:\tAURORA_USER_ID, AURORA_KEY, AURORA_ENDPOINT")
fmt.Fprintln(w, "\tbluecat:\tBLUECAT_SERVER_URL, BLUECAT_USER_NAME, BLUECAT_PASSWORD, BLUECAT_CONFIG_NAME, BLUECAT_DNS_VIEW")
fmt.Fprintln(w, "\tcloudxns:\tCLOUDXNS_API_KEY, CLOUDXNS_SECRET_KEY")
fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY")
fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN")
fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_OAUTH_TOKEN")
fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET")
fmt.Fprintln(w, "\tduckdns:\tDUCKDNS_TOKEN")
fmt.Fprintln(w, "\texoscale:\tEXOSCALE_API_KEY, EXOSCALE_API_SECRET, EXOSCALE_ENDPOINT")
fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY")
fmt.Fprintln(w, "\tgandiv5:\tGANDIV5_API_KEY")
fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT, GCE_SERVICE_ACCOUNT_FILE")
fmt.Fprintln(w, "\tglesys:\tGLESYS_API_USER, GLESYS_API_KEY")
fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY")
fmt.Fprintln(w, "\tlightsail:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, DNS_ZONE")
fmt.Fprintln(w, "\tmanual:\tnone")
fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY")
fmt.Fprintln(w, "\tnamedotcom:\tNAMECOM_USERNAME, NAMECOM_API_TOKEN")
fmt.Fprintln(w, "\trackspace:\tRACKSPACE_USER, RACKSPACE_API_KEY")
fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER")
fmt.Fprintln(w, "\troute53:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_HOSTED_ZONE_ID")
@ -220,6 +225,7 @@ Here is an example bash command using the CloudFlare DNS provider:
fmt.Fprintln(w, "\tpdns:\tPDNS_API_KEY, PDNS_API_URL")
fmt.Fprintln(w, "\tdnspod:\tDNSPOD_API_KEY")
fmt.Fprintln(w, "\totc:\tOTC_USER_NAME, OTC_PASSWORD, OTC_PROJECT_NAME, OTC_DOMAIN_NAME, OTC_IDENTITY_ENDPOINT")
fmt.Fprintln(w, "\texec:\tEXEC_PATH")
w.Flush()
fmt.Println(`

View file

@ -27,7 +27,6 @@ type DNSProvider struct {
subscriptionId string
tenantId string
resourceGroup string
context context.Context
}
@ -47,7 +46,13 @@ func NewDNSProvider() (*DNSProvider, error) {
// DNSProvider instance configured for azure.
func NewDNSProviderCredentials(clientId, clientSecret, subscriptionId, tenantId, resourceGroup string) (*DNSProvider, error) {
if clientId == "" || clientSecret == "" || subscriptionId == "" || tenantId == "" || resourceGroup == "" {
return nil, fmt.Errorf("Azure configuration missing")
missingEnvVars := []string{}
for _, envVar := range []string{"AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", "AZURE_RESOURCE_GROUP"} {
if os.Getenv(envVar) == "" {
missingEnvVars = append(missingEnvVars, envVar)
}
}
return nil, fmt.Errorf("Azure configuration missing: %s", strings.Join(missingEnvVars, ","))
}
return &DNSProvider{
@ -57,7 +62,7 @@ func NewDNSProviderCredentials(clientId, clientSecret, subscriptionId, tenantId,
tenantId: tenantId,
resourceGroup: resourceGroup,
// TODO: A timeout can be added here for cancellation purposes.
context: context.Background(),
context: context.Background(),
}, nil
}

View file

@ -58,7 +58,7 @@ func TestNewDNSProviderValidEnv(t *testing.T) {
func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("AZURE_SUBSCRIPTION_ID", "")
_, err := NewDNSProvider()
assert.EqualError(t, err, "Azure configuration missing")
assert.EqualError(t, err, "Azure configuration missing: AZURE_CLIENT_ID,AZURE_CLIENT_SECRET,AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID,AZURE_RESOURCE_GROUP")
restoreAzureEnv()
}

View file

@ -0,0 +1,418 @@
// Package bluecat implements a DNS provider for solving the DNS-01 challenge
// using a self-hosted Bluecat Address Manager.
package bluecat
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/xenolf/lego/acme"
"io/ioutil"
)
const bluecatUrlTemplate = "%s/Services/REST/v1"
const configType = "Configuration"
const viewType = "View"
const txtType = "TXTRecord"
const zoneType = "Zone"
type entityResponse struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Properties string `json:"properties"`
}
// DNSProvider is an implementation of the acme.ChallengeProvider interface that uses
// Bluecat's Address Manager REST API to manage TXT records for a domain.
type DNSProvider struct {
baseUrl string
userName string
password string
configName string
dnsView string
token string
httpClient *http.Client
}
// NewDNSProvider returns a DNSProvider instance configured for Bluecat DNS.
// Credentials must be passed in the environment variables: BLUECAT_SERVER_URL,
// BLUECAT_USER_NAME and BLUECAT_PASSWORD. BLUECAT_SERVER_URL should have the
// scheme, hostname, and port (if required) of the authoritative Bluecat BAM
// server. 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) {
server := os.Getenv("BLUECAT_SERVER_URL")
userName := os.Getenv("BLUECAT_USER_NAME")
password := os.Getenv("BLUECAT_PASSWORD")
configName := os.Getenv("BLUECAT_CONFIG_NAME")
dnsView := os.Getenv("BLUECAT_DNS_VIEW")
httpClient := http.Client{Timeout: time.Duration(30 * time.Second)}
return NewDNSProviderCredentials(server, userName, password, configName, dnsView, httpClient)
}
// NewDNSProviderCredentials uses the supplied credentials to return a
// DNSProvider instance configured for Bluecat DNS.
func NewDNSProviderCredentials(server, userName, password, configName, dnsView string, httpClient http.Client) (*DNSProvider, error) {
if server == "" || userName == "" || password == "" || configName == "" || dnsView == "" {
return nil, fmt.Errorf("Bluecat credentials missing")
}
return &DNSProvider{
baseUrl: fmt.Sprintf(bluecatUrlTemplate, server),
userName: userName,
password: password,
configName: configName,
dnsView: dnsView,
httpClient: http.DefaultClient,
}, nil
}
// Send a REST request, using query parameters specified. The Authorization
// header will be set if we have an active auth token
func (d *DNSProvider) sendRequest(method, resource string, payload interface{}, queryArgs map[string]string) (*http.Response, error) {
url := fmt.Sprintf("%s/%s", d.baseUrl, resource)
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if len(d.token) > 0 {
req.Header.Set("Authorization", d.token)
}
// Add all query parameters
q := req.URL.Query()
for argName, argVal := range queryArgs {
q.Add(argName, argVal)
}
req.URL.RawQuery = q.Encode()
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
errBytes, _ := ioutil.ReadAll(resp.Body)
errResp := string(errBytes)
return nil, fmt.Errorf("Bluecat API request failed with HTTP status code %d\n Full message: %s",
resp.StatusCode, errResp)
}
return resp, nil
}
// Starts a new Bluecat API Session. Authenticates using customerName, userName,
// password and receives a token to be used in for subsequent requests.
func (d *DNSProvider) login() error {
queryArgs := map[string]string{
"username": d.userName,
"password": d.password,
}
resp, err := d.sendRequest("GET", "login", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
authBytes, _ := ioutil.ReadAll(resp.Body)
authResp := string(authBytes)
if strings.Contains(authResp, "Authentication Error") {
msg := strings.Trim(authResp, "\"")
return fmt.Errorf("Bluecat API request failed: %s", msg)
}
// Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username"
re := regexp.MustCompile("BAMAuthToken: [^ ]+")
token := re.FindString(authResp)
d.token = token
return nil
}
// Destroys Bluecat Session
func (d *DNSProvider) logout() error {
if len(d.token) == 0 {
// nothing to do
return nil
}
resp, err := d.sendRequest("GET", "logout", nil, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("Bluecat API request failed to delete session with HTTP status code %d", resp.StatusCode)
} else {
authBytes, _ := ioutil.ReadAll(resp.Body)
authResp := string(authBytes)
if !strings.Contains(authResp, "successfully") {
msg := strings.Trim(authResp, "\"")
return fmt.Errorf("Bluecat API request failed to delete session: %s", msg)
}
}
d.token = ""
return nil
}
// Lookup the entity ID of the configuration named in our properties
func (d *DNSProvider) lookupConfId() (uint, error) {
queryArgs := map[string]string{
"parentId": strconv.Itoa(0),
"name": d.configName,
"type": configType,
}
resp, err := d.sendRequest("GET", "getEntityByName", nil, queryArgs)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var conf entityResponse
err = json.NewDecoder(resp.Body).Decode(&conf)
if err != nil {
return 0, err
}
return conf.Id, nil
}
// Find the DNS view with the given name within
func (d *DNSProvider) lookupViewId(viewName string) (uint, error) {
confId, err := d.lookupConfId()
if err != nil {
return 0, err
}
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(confId), 10),
"name": d.dnsView,
"type": viewType,
}
resp, err := d.sendRequest("GET", "getEntityByName", nil, queryArgs)
if err != nil {
return 0, err
}
defer resp.Body.Close()
var view entityResponse
err = json.NewDecoder(resp.Body).Decode(&view)
if err != nil {
return 0, err
}
return view.Id, nil
}
// Return the entityId of the parent zone by recursing from the root view
// Also return the simple name of the host
func (d *DNSProvider) lookupParentZoneId(viewId uint, fqdn string) (uint, string, error) {
parentViewId := viewId
name := ""
if fqdn != "" {
zones := strings.Split(strings.Trim(fqdn, "."), ".")
last := len(zones) - 1
name = zones[0]
for i := last; i > -1; i-- {
zoneId, err := d.getZone(parentViewId, zones[i])
if err != nil || zoneId == 0 {
return parentViewId, name, err
}
if (i > 0) {
name = strings.Join(zones[0:i],".")
}
parentViewId = zoneId
}
}
return parentViewId, name, nil
}
// Get the DNS zone with the specified name under the parentId
func (d *DNSProvider) getZone(parentId uint, name string) (uint, error) {
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(parentId), 10),
"name": name,
"type": zoneType,
}
resp, err := d.sendRequest("GET", "getEntityByName", nil, queryArgs)
// Return an empty zone if the named zone doesn't exist
if resp != nil && resp.StatusCode == 404 {
return 0, fmt.Errorf("Bluecat API could not find zone named %s", name)
}
if err != nil {
return 0, err
}
defer resp.Body.Close()
var zone entityResponse
err = json.NewDecoder(resp.Body).Decode(&zone)
if err != nil {
return 0, err
}
return zone.Id, nil
}
// Present creates a TXT record using the specified parameters
// This will *not* create a subzone to contain the TXT record,
// so make sure the FQDN specified is within an extant zone.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
err := d.login()
if err != nil {
return err
}
viewId, err := d.lookupViewId(d.dnsView)
if err != nil {
return err
}
parentZoneId, name, err := d.lookupParentZoneId(viewId, fqdn)
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(parentZoneId), 10),
}
body := bluecatEntity{
Name: name,
Type: "TXTRecord",
Properties: fmt.Sprintf("ttl=%d|absoluteName=%s|txt=%s|", ttl, fqdn, value),
}
resp, err := d.sendRequest("POST", "addEntity", body, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
addTxtBytes, _ := ioutil.ReadAll(resp.Body)
addTxtResp := string(addTxtBytes)
// addEntity responds only with body text containing the ID of the created record
_, err = strconv.ParseUint(addTxtResp, 10, 64)
if err != nil {
return fmt.Errorf("Bluecat API addEntity request failed: %s", addTxtResp)
}
err = d.deploy(uint(parentZoneId))
if err != nil {
return err
}
err = d.logout()
if err != nil {
return err
}
return nil
}
// Deploy the DNS config for the specified entity to the authoritative servers
func (d *DNSProvider) deploy(entityId uint) error {
queryArgs := map[string]string{
"entityId": strconv.FormatUint(uint64(entityId), 10),
}
resp, err := d.sendRequest("POST", "quickDeploy", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
err := d.login()
if err != nil {
return err
}
viewId, err := d.lookupViewId(d.dnsView)
if err != nil {
return err
}
parentId, name, err := d.lookupParentZoneId(viewId, fqdn)
if err != nil {
return err
}
queryArgs := map[string]string{
"parentId": strconv.FormatUint(uint64(parentId), 10),
"name": name,
"type": txtType,
}
resp, err := d.sendRequest("GET", "getEntityByName", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
var txtRec entityResponse
err = json.NewDecoder(resp.Body).Decode(&txtRec)
if err != nil {
return err
}
queryArgs = map[string]string{
"objectId": strconv.FormatUint(uint64(txtRec.Id), 10),
}
resp, err = d.sendRequest("DELETE", "delete", nil, queryArgs)
if err != nil {
return err
}
defer resp.Body.Close()
err = d.deploy(parentId)
if err != nil {
return err
}
err = d.logout()
if err != nil {
return err
}
return nil
}
//JSON body for Bluecat entity requests and responses
type bluecatEntity struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Properties string `json:"properties"`
}

View file

@ -0,0 +1,57 @@
package bluecat
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"time"
)
var (
bluecatLiveTest bool
bluecatServer string
bluecatUserName string
bluecatPassword string
bluecatConfigName string
bluecatDnsView string
bluecatDomain string
)
func init() {
bluecatServer = os.Getenv("BLUECAT_SERVER_URL")
bluecatUserName = os.Getenv("BLUECAT_USER_NAME")
bluecatPassword = os.Getenv("BLUECAT_PASSWORD")
bluecatDomain = os.Getenv("BLUECAT_DOMAIN")
bluecatConfigName = os.Getenv("BLUECAT_CONFIG_NAME")
bluecatDnsView = os.Getenv("BLUECAT_DNS_VIEW")
if len(bluecatServer) > 0 && len(bluecatDomain) > 0 && len(bluecatUserName) > 0 && len(bluecatPassword) > 0 && len(bluecatConfigName) > 0 && len(bluecatDnsView) > 0 {
bluecatLiveTest = true
}
}
func TestLiveBluecatPresent(t *testing.T) {
if !bluecatLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProvider()
assert.NoError(t, err)
err = provider.Present(bluecatDomain, "", "123d==")
assert.NoError(t, err)
}
func TestLiveBluecatCleanUp(t *testing.T) {
if !bluecatLiveTest {
t.Skip("skipping live test")
}
time.Sleep(time.Second * 1)
provider, err := NewDNSProvider()
assert.NoError(t, err)
err = provider.CleanUp(bluecatDomain, "", "123d==")
assert.NoError(t, err)
}

View file

@ -9,6 +9,7 @@ import (
"io"
"net/http"
"os"
"strings"
"time"
"github.com/xenolf/lego/acme"
@ -37,7 +38,14 @@ func NewDNSProvider() (*DNSProvider, error) {
// DNSProvider instance configured for cloudflare.
func NewDNSProviderCredentials(email, key string) (*DNSProvider, error) {
if email == "" || key == "" {
return nil, fmt.Errorf("CloudFlare credentials missing")
missingEnvVars := []string{}
if email == "" {
missingEnvVars = append(missingEnvVars, "CLOUDFLARE_EMAIL")
}
if key == "" {
missingEnvVars = append(missingEnvVars, "CLOUDFLARE_API_KEY")
}
return nil, fmt.Errorf("CloudFlare credentials missing: %s", strings.Join(missingEnvVars, ","))
}
return &DNSProvider{

View file

@ -49,10 +49,17 @@ func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("CLOUDFLARE_EMAIL", "")
os.Setenv("CLOUDFLARE_API_KEY", "")
_, err := NewDNSProvider()
assert.EqualError(t, err, "CloudFlare credentials missing")
assert.EqualError(t, err, "CloudFlare credentials missing: CLOUDFLARE_EMAIL,CLOUDFLARE_API_KEY")
restoreCloudFlareEnv()
}
func TestNewDNSProviderMissingCredErrSingle(t *testing.T){
os.Setenv("CLOUDFLARE_EMAIL", "awesome@possum.com")
_, err:= NewDNSProvider()
assert.EqualError(t, err, "CloudFlare credentials missing: CLOUDFLARE_API_KEY")
restoreCloudFlareEnv()
}
func TestCloudFlarePresent(t *testing.T) {
if !cflareLiveTest {
t.Skip("skipping live test")

View file

@ -13,14 +13,20 @@ import (
"github.com/xenolf/lego/providers/dns/dnsimple"
"github.com/xenolf/lego/providers/dns/dnsmadeeasy"
"github.com/xenolf/lego/providers/dns/dnspod"
"github.com/xenolf/lego/providers/dns/duckdns"
"github.com/xenolf/lego/providers/dns/dyn"
"github.com/xenolf/lego/providers/dns/exec"
"github.com/xenolf/lego/providers/dns/exoscale"
"github.com/xenolf/lego/providers/dns/fastdns"
"github.com/xenolf/lego/providers/dns/gandi"
"github.com/xenolf/lego/providers/dns/gandiv5"
"github.com/xenolf/lego/providers/dns/googlecloud"
"github.com/xenolf/lego/providers/dns/glesys"
"github.com/xenolf/lego/providers/dns/godaddy"
"github.com/xenolf/lego/providers/dns/googlecloud"
"github.com/xenolf/lego/providers/dns/lightsail"
"github.com/xenolf/lego/providers/dns/linode"
"github.com/xenolf/lego/providers/dns/namecheap"
"github.com/xenolf/lego/providers/dns/namedotcom"
"github.com/xenolf/lego/providers/dns/ns1"
"github.com/xenolf/lego/providers/dns/otc"
"github.com/xenolf/lego/providers/dns/ovh"
@ -29,6 +35,7 @@ import (
"github.com/xenolf/lego/providers/dns/rfc2136"
"github.com/xenolf/lego/providers/dns/route53"
"github.com/xenolf/lego/providers/dns/vultr"
"github.com/xenolf/lego/providers/dns/bluecat"
)
func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) {
@ -39,6 +46,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
provider, err = azure.NewDNSProvider()
case "auroradns":
provider, err = auroradns.NewDNSProvider()
case "bluecat":
provider, err = bluecat.NewDNSProvider()
case "cloudflare":
provider, err = cloudflare.NewDNSProvider()
case "cloudxns":
@ -51,24 +60,34 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
provider, err = dnsmadeeasy.NewDNSProvider()
case "dnspod":
provider, err = dnspod.NewDNSProvider()
case "duckdns":
provider, err = duckdns.NewDNSProvider()
case "dyn":
provider, err = dyn.NewDNSProvider()
case "fastdns":
provider, err = fastdns.NewDNSProvider()
case "exoscale":
provider, err = exoscale.NewDNSProvider()
case "gandi":
provider, err = gandi.NewDNSProvider()
case "gandiv5":
provider, err = gandiv5.NewDNSProvider()
case "glesys":
provider, err = glesys.NewDNSProvider()
case "gcloud":
provider, err = googlecloud.NewDNSProvider()
case "godaddy":
provider, err = godaddy.NewDNSProvider()
case "lightsail":
provider, err = lightsail.NewDNSProvider()
case "linode":
provider, err = linode.NewDNSProvider()
case "manual":
provider, err = acme.NewDNSProviderManual()
case "namecheap":
provider, err = namecheap.NewDNSProvider()
case "namedotcom":
provider, err = namedotcom.NewDNSProvider()
case "rackspace":
provider, err = rackspace.NewDNSProvider()
case "route53":
@ -85,6 +104,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
provider, err = ns1.NewDNSProvider()
case "otc":
provider, err = otc.NewDNSProvider()
case "exec":
provider, err = exec.NewDNSProvider()
default:
err = fmt.Errorf("Unrecognised DNS provider: %s", name)
}

View file

@ -176,5 +176,5 @@ func (c *DNSProvider) getAccountID() (string, error) {
return "", fmt.Errorf("DNSimple user tokens are not supported, please use an account token.")
}
return strconv.Itoa(whoamiResponse.Data.Account.ID), nil
return strconv.FormatInt(whoamiResponse.Data.Account.ID, 10), nil
}

View file

@ -0,0 +1,82 @@
// Adds lego support for http://duckdns.org .
//
// See http://www.duckdns.org/spec.jsp for more info on updating TXT records.
package duckdns
import (
"errors"
"fmt"
"io/ioutil"
"os"
"github.com/xenolf/lego/acme"
)
// DNSProvider adds and removes the record for the DNS challenge
type DNSProvider struct {
// The duckdns api token
token string
}
// NewDNSProvider returns a new DNS provider using
// environment variable DUCKDNS_TOKEN for adding and removing the DNS record.
func NewDNSProvider() (*DNSProvider, error) {
duckdnsToken := os.Getenv("DUCKDNS_TOKEN")
return NewDNSProviderCredentials(duckdnsToken)
}
// NewDNSProviderCredentials uses the supplied credentials to return a
// DNSProvider instance configured for http://duckdns.org .
func NewDNSProviderCredentials(duckdnsToken string) (*DNSProvider, error) {
if duckdnsToken == "" {
return nil, errors.New("environment variable DUCKDNS_TOKEN not set")
}
return &DNSProvider{token: duckdnsToken}, nil
}
// makeDuckdnsURL creates a url to clear the set or unset the TXT record.
// txt == "" will clear the TXT record.
func makeDuckdnsURL(domain, token, txt string) string {
requestBase := fmt.Sprintf("https://www.duckdns.org/update?domains=%s&token=%s", domain, token)
if txt == "" {
return requestBase + "&clear=true"
}
return requestBase + "&txt=" + txt
}
func issueDuckdnsRequest(url string) error {
response, err := acme.HTTPClient.Get(url)
if err != nil {
return err
}
defer response.Body.Close()
bodyBytes, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
body := string(bodyBytes)
if body != "OK" {
return fmt.Errorf("Request to change TXT record for duckdns returned the following result (%s) this does not match expectation (OK) used url [%s]", body, url)
}
return nil
}
// Present creates a TXT record to fulfil the dns-01 challenge.
// In duckdns you only have one TXT record shared with
// the domain and all sub domains.
//
// To update the TXT record we just need to make one simple get request.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
_, txtRecord, _ := acme.DNS01Record(domain, keyAuth)
url := makeDuckdnsURL(domain, d.token, txtRecord)
return issueDuckdnsRequest(url)
}
// CleanUp clears duckdns TXT record
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
url := makeDuckdnsURL(domain, d.token, "")
return issueDuckdnsRequest(url)
}

View file

@ -0,0 +1,65 @@
package duckdns
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
var (
duckdnsLiveTest bool
duckdnsToken string
duckdnsDomain string
)
func init() {
duckdnsToken = os.Getenv("DUCKDNS_TOKEN")
duckdnsDomain = os.Getenv("DUCKDNS_DOMAIN")
if len(duckdnsDomain) > 0 && len(duckdnsDomain) > 0 {
duckdnsLiveTest = true
}
}
func restoreDuckdnsEnv() {
os.Setenv("DUCKDNS_TOKEN", duckdnsToken)
}
func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("DUCKDNS_TOKEN", "123")
_, err := NewDNSProvider()
assert.NoError(t, err)
restoreDuckdnsEnv()
}
func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("DUCKDNS_TOKEN", "")
_, err := NewDNSProvider()
assert.EqualError(t, err, "environment variable DUCKDNS_TOKEN not set")
restoreDuckdnsEnv()
}
func TestLiveDuckdnsPresent(t *testing.T) {
if !duckdnsLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProvider()
assert.NoError(t, err)
err = provider.Present(duckdnsDomain, "", "123d==")
assert.NoError(t, err)
}
func TestLiveDuckdnsCleanUp(t *testing.T) {
if !duckdnsLiveTest {
t.Skip("skipping live test")
}
time.Sleep(time.Second * 10)
provider, err := NewDNSProvider()
assert.NoError(t, err)
err = provider.CleanUp(duckdnsDomain, "", "123d==")
assert.NoError(t, err)
}

View file

@ -87,11 +87,8 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{})
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
if resp.StatusCode >= 500 {
return nil, fmt.Errorf("Dyn API request failed with HTTP status code %d", resp.StatusCode)
} else if resp.StatusCode == 307 {
// TODO add support for HTTP 307 response and long running jobs
return nil, fmt.Errorf("Dyn API request returned HTTP 307. This is currently unsupported")
}
var dynRes dynResponse
@ -100,6 +97,13 @@ func (d *DNSProvider) sendRequest(method, resource string, payload interface{})
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("Dyn API request failed with HTTP status code %d: %s", resp.StatusCode, dynRes.Messages)
} else if resp.StatusCode == 307 {
// TODO add support for HTTP 307 response and long running jobs
return nil, fmt.Errorf("Dyn API request returned HTTP 307. This is currently unsupported")
}
if dynRes.Status == "failure" {
// TODO add better error handling
return nil, fmt.Errorf("Dyn API request failed: %s", dynRes.Messages)

View file

@ -0,0 +1,72 @@
// Package exec implements a manual DNS provider which runs a program for
// adding/removing the DNS record.
//
// The file name of the external program is specified in the environment
// variable EXEC_PATH. When it is run by lego, three command-line parameters
// are passed to it: The action ("present" or "cleanup"), the fully-qualified domain
// name, the value for the record and the TTL.
//
// For example, requesting a certificate for the domain 'foo.example.com' can
// be achieved by calling lego as follows:
//
// EXEC_PATH=./update-dns.sh \
// lego --dns exec \
// --domains foo.example.com \
// --email invalid@example.com run
//
// It will then call the program './update-dns.sh' with like this:
//
// ./update-dns.sh "present" "_acme-challenge.foo.example.com." "MsijOYZxqyjGnFGwhjrhfg-Xgbl5r68WPda0J9EgqqI" "120"
//
// The program then needs to make sure the record is inserted. When it returns
// an error via a non-zero exit code, lego aborts.
//
// When the record is to be removed again, the program is called with the first
// command-line parameter set to "cleanup" instead of "present".
package exec
import (
"errors"
"os"
"os/exec"
"strconv"
"github.com/xenolf/lego/acme"
)
// DNSProvider adds and removes the record for the DNS challenge by calling a
// program with command-line parameters.
type DNSProvider struct {
program string
}
// NewDNSProvider returns a new DNS provider which runs the program in the
// environment variable EXEC_PATH for adding and removing the DNS record.
func NewDNSProvider() (*DNSProvider, error) {
s := os.Getenv("EXEC_PATH")
if s == "" {
return nil, errors.New("environment variable EXEC_PATH not set")
}
return &DNSProvider{program: s}, nil
}
// Present creates a TXT record to fulfil the dns-01 challenge.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
cmd := exec.Command(d.program, "present", fqdn, value, strconv.Itoa(ttl))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// CleanUp removes the TXT record matching the specified parameters
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
cmd := exec.Command(d.program, "cleanup", fqdn, value, strconv.Itoa(ttl))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

View file

@ -0,0 +1,139 @@
package fastdns
import (
"fmt"
"os"
"reflect"
configdns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v1"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
"github.com/xenolf/lego/acme"
)
// DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct {
config edgegrid.Config
}
// NewDNSProvider uses the supplied environment variables to return a DNSProvider instance:
// AKAMAI_HOST, AKAMAI_CLIENT_TOKEN, AKAMAI_CLIENT_SECRET, AKAMAI_ACCESS_TOKEN
func NewDNSProvider() (*DNSProvider, error) {
host := os.Getenv("AKAMAI_HOST")
clientToken := os.Getenv("AKAMAI_CLIENT_TOKEN")
clientSecret := os.Getenv("AKAMAI_CLIENT_SECRET")
accessToken := os.Getenv("AKAMAI_ACCESS_TOKEN")
return NewDNSProviderClient(host, clientToken, clientSecret, accessToken)
}
// NewDNSProviderClient uses the supplied parameters to return a DNSProvider instance
// configured for FastDNS.
func NewDNSProviderClient(host, clientToken, clientSecret, accessToken string) (*DNSProvider, error) {
if clientToken == "" || clientSecret == "" || accessToken == "" || host == "" {
return nil, fmt.Errorf("Akamai FastDNS credentials missing")
}
config := edgegrid.Config{
Host: host,
ClientToken: clientToken,
ClientSecret: clientSecret,
AccessToken: accessToken,
MaxBody: 131072,
}
return &DNSProvider{
config: config,
}, nil
}
// Present creates a TXT record to fullfil the dns-01 challenge.
func (c *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
zoneName, recordName, err := c.findZoneAndRecordName(fqdn, domain)
if err != nil {
return err
}
configdns.Init(c.config)
zone, err := configdns.GetZone(zoneName)
if err != nil {
return err
}
record := configdns.NewTxtRecord()
record.SetField("name", recordName)
record.SetField("ttl", ttl)
record.SetField("target", value)
record.SetField("active", true)
existingRecord := c.findExistingRecord(zone, recordName)
if existingRecord != nil {
if reflect.DeepEqual(existingRecord.ToMap(), record.ToMap()) {
return nil
}
zone.RemoveRecord(existingRecord)
return c.createRecord(zone, record)
}
return c.createRecord(zone, record)
}
// CleanUp removes the record matching the specified parameters.
func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
zoneName, recordName, err := c.findZoneAndRecordName(fqdn, domain)
if err != nil {
return err
}
configdns.Init(c.config)
zone, err := configdns.GetZone(zoneName)
if err != nil {
return err
}
existingRecord := c.findExistingRecord(zone, recordName)
if existingRecord != nil {
err := zone.RemoveRecord(existingRecord)
if err != nil {
return err
}
return zone.Save()
}
return nil
}
func (c *DNSProvider) findZoneAndRecordName(fqdn, domain string) (string, string, error) {
zone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers)
if err != nil {
return "", "", err
}
zone = acme.UnFqdn(zone)
name := acme.UnFqdn(fqdn)
name = name[:len(name)-len("."+zone)]
return zone, name, nil
}
func (c *DNSProvider) findExistingRecord(zone *configdns.Zone, recordName string) *configdns.TxtRecord {
for _, r := range zone.Zone.Txt {
if r.Name == recordName {
return r
}
}
return nil
}
func (c *DNSProvider) createRecord(zone *configdns.Zone, record *configdns.TxtRecord) error {
err := zone.AddRecord(record)
if err != nil {
return err
}
return zone.Save()
}

View file

@ -0,0 +1,117 @@
package fastdns
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
var (
fastdnsLiveTest bool
host string
clientToken string
clientSecret string
accessToken string
testDomain string
)
func init() {
host = os.Getenv("AKAMAI_HOST")
clientToken = os.Getenv("AKAMAI_CLIENT_TOKEN")
clientSecret = os.Getenv("AKAMAI_CLIENT_SECRET")
accessToken = os.Getenv("AKAMAI_ACCESS_TOKEN")
testDomain = os.Getenv("AKAMAI_TEST_DOMAIN")
if len(host) > 0 && len(clientToken) > 0 && len(clientSecret) > 0 && len(accessToken) > 0 {
fastdnsLiveTest = true
}
}
func restoreFastdnsEnv() {
os.Setenv("AKAMAI_HOST", host)
os.Setenv("AKAMAI_CLIENT_TOKEN", clientToken)
os.Setenv("AKAMAI_CLIENT_SECRET", clientSecret)
os.Setenv("AKAMAI_ACCESS_TOKEN", accessToken)
}
func TestNewDNSProviderValid(t *testing.T) {
os.Setenv("AKAMAI_HOST", "")
os.Setenv("AKAMAI_CLIENT_TOKEN", "")
os.Setenv("AKAMAI_CLIENT_SECRET", "")
os.Setenv("AKAMAI_ACCESS_TOKEN", "")
_, err := NewDNSProviderClient("somehost", "someclienttoken", "someclientsecret", "someaccesstoken")
assert.NoError(t, err)
restoreFastdnsEnv()
}
func TestNewDNSProviderValidEnv(t *testing.T) {
os.Setenv("AKAMAI_HOST", "somehost")
os.Setenv("AKAMAI_CLIENT_TOKEN", "someclienttoken")
os.Setenv("AKAMAI_CLIENT_SECRET", "someclientsecret")
os.Setenv("AKAMAI_ACCESS_TOKEN", "someaccesstoken")
_, err := NewDNSProvider()
assert.NoError(t, err)
restoreFastdnsEnv()
}
func TestNewDNSProviderMissingCredErr(t *testing.T) {
os.Setenv("AKAMAI_HOST", "")
os.Setenv("AKAMAI_CLIENT_TOKEN", "")
os.Setenv("AKAMAI_CLIENT_SECRET", "")
os.Setenv("AKAMAI_ACCESS_TOKEN", "")
_, err := NewDNSProvider()
assert.EqualError(t, err, "Akamai FastDNS credentials missing")
restoreFastdnsEnv()
}
func TestLiveFastdnsPresent(t *testing.T) {
if !fastdnsLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProviderClient(host, clientToken, clientSecret, accessToken)
assert.NoError(t, err)
err = provider.Present(testDomain, "", "123d==")
assert.NoError(t, err)
// Present Twice to handle create / update
err = provider.Present(testDomain, "", "123d==")
assert.NoError(t, err)
}
func TestExtractRootRecordName(t *testing.T) {
provider, err := NewDNSProviderClient("somehost", "someclienttoken", "someclientsecret", "someaccesstoken")
assert.NoError(t, err)
zone, recordName, err := provider.findZoneAndRecordName("_acme-challenge.bar.com.", "bar.com")
assert.NoError(t, err)
assert.Equal(t, "bar.com", zone)
assert.Equal(t, "_acme-challenge", recordName)
}
func TestExtractSubRecordName(t *testing.T) {
provider, err := NewDNSProviderClient("somehost", "someclienttoken", "someclientsecret", "someaccesstoken")
assert.NoError(t, err)
zone, recordName, err := provider.findZoneAndRecordName("_acme-challenge.foo.bar.com.", "foo.bar.com")
assert.NoError(t, err)
assert.Equal(t, "bar.com", zone)
assert.Equal(t, "_acme-challenge.foo", recordName)
}
func TestLiveFastdnsCleanUp(t *testing.T) {
if !fastdnsLiveTest {
t.Skip("skipping live test")
}
time.Sleep(time.Second * 1)
provider, err := NewDNSProviderClient(host, clientToken, clientSecret, accessToken)
assert.NoError(t, err)
err = provider.CleanUp(testDomain, "", "123d==")
assert.NoError(t, err)
}

View file

@ -0,0 +1,211 @@
// Package glesys implements a DNS provider for solving the DNS-01
// challenge using GleSYS api.
package glesys
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/xenolf/lego/acme"
)
// GleSYS API reference: https://github.com/GleSYS/API/wiki/API-Documentation
// domainAPI is the GleSYS API endpoint used by Present and CleanUp.
const domainAPI = "https://api.glesys.com/domain"
var (
// Logger is used to log API communication results;
// if nil, the default log.Logger is used.
Logger *log.Logger
)
// logf writes a log entry. It uses Logger if not
// nil, otherwise it uses the default log.Logger.
func logf(format string, args ...interface{}) {
if Logger != nil {
Logger.Printf(format, args...)
} else {
log.Printf(format, args...)
}
}
// DNSProvider is an implementation of the
// acme.ChallengeProviderTimeout interface that uses GleSYS
// API to manage TXT records for a domain.
type DNSProvider struct {
apiUser string
apiKey string
activeRecords map[string]int
inProgressMu sync.Mutex
}
// NewDNSProvider returns a DNSProvider instance configured for GleSYS.
// Credentials must be passed in the environment variables: GLESYS_API_USER
// and GLESYS_API_KEY.
func NewDNSProvider() (*DNSProvider, error) {
apiUser := os.Getenv("GLESYS_API_USER")
apiKey := os.Getenv("GLESYS_API_KEY")
return NewDNSProviderCredentials(apiUser, apiKey)
}
// NewDNSProviderCredentials uses the supplied credentials to return a
// DNSProvider instance configured for GleSYS.
func NewDNSProviderCredentials(apiUser string, apiKey string) (*DNSProvider, error) {
if apiUser == "" || apiKey == "" {
return nil, fmt.Errorf("GleSYS DNS: Incomplete credentials provided")
}
return &DNSProvider{
apiUser: apiUser,
apiKey: apiKey,
activeRecords: make(map[string]int),
}, nil
}
// Present creates a TXT record using the specified parameters.
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
if ttl < 60 {
ttl = 60 // 60 is GleSYS minimum value for ttl
}
// find authZone
authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers)
if err != nil {
return fmt.Errorf("GleSYS DNS: findZoneByFqdn failure: %v", err)
}
// determine name of TXT record
if !strings.HasSuffix(
strings.ToLower(fqdn), strings.ToLower("."+authZone)) {
return fmt.Errorf(
"GleSYS DNS: unexpected authZone %s for fqdn %s", authZone, fqdn)
}
name := fqdn[:len(fqdn)-len("."+authZone)]
// acquire lock and check there is not a challenge already in
// progress for this value of authZone
d.inProgressMu.Lock()
defer d.inProgressMu.Unlock()
// add TXT record into authZone
recordId, err := d.addTXTRecord(domain, acme.UnFqdn(authZone), name, value, ttl)
if err != nil {
return err
}
// save data necessary for CleanUp
d.activeRecords[fqdn] = recordId
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
// acquire lock and retrieve authZone
d.inProgressMu.Lock()
defer d.inProgressMu.Unlock()
if _, ok := d.activeRecords[fqdn]; !ok {
// if there is no cleanup information then just return
return nil
}
recordId := d.activeRecords[fqdn]
delete(d.activeRecords, fqdn)
// delete TXT record from authZone
err := d.deleteTXTRecord(domain, recordId)
if err != nil {
return err
}
return nil
}
// Timeout returns the values (20*time.Minute, 20*time.Second) which
// are used by the acme package as timeout and check interval values
// when checking for DNS record propagation with GleSYS.
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return 20 * time.Minute, 20 * time.Second
}
// types for JSON method calls, parameters, and responses
type addRecordRequest struct {
Domainname string `json:"domainname"`
Host string `json:"host"`
Type string `json:"type"`
Data string `json:"data"`
Ttl int `json:"ttl,omitempty"`
}
type deleteRecordRequest struct {
Recordid int `json:"recordid"`
}
type responseStruct struct {
Response struct {
Status struct {
Code int `json:"code"`
} `json:"status"`
Record deleteRecordRequest `json:"record"`
} `json:"response"`
}
// POSTing/Marshalling/Unmarshalling
func (d *DNSProvider) sendRequest(method string, resource string, payload interface{}) (*responseStruct, error) {
url := fmt.Sprintf("%s/%s", domainAPI, resource)
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(d.apiUser, d.apiKey)
client := &http.Client{Timeout: time.Duration(10 * time.Second)}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("GleSYS DNS: request failed with HTTP status code %d", resp.StatusCode)
}
var response responseStruct
err = json.NewDecoder(resp.Body).Decode(&response)
return &response, err
}
// functions to perform API actions
func (d *DNSProvider) addTXTRecord(fqdn string, domain string, name string, value string, ttl int) (int, error) {
response, err := d.sendRequest("POST", "addrecord", addRecordRequest{
Domainname: domain,
Host: name,
Type: "TXT",
Data: value,
Ttl: ttl,
})
if response != nil && response.Response.Status.Code == 200 {
logf("[INFO][%s] GleSYS DNS: Successfully created recordid %d", fqdn, response.Response.Record.Recordid)
return response.Response.Record.Recordid, nil
}
return 0, err
}
func (d *DNSProvider) deleteTXTRecord(fqdn string, recordid int) error {
response, err := d.sendRequest("POST", "deleterecord", deleteRecordRequest{
Recordid: recordid,
})
if response != nil && response.Response.Status.Code == 200 {
logf("[INFO][%s] GleSYS DNS: Successfully deleted recordid %d", fqdn, recordid)
}
return err
}

View file

@ -0,0 +1,60 @@
package glesys
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
var (
glesysAPIUser string
glesysAPIKey string
glesysDomain string
glesysLiveTest bool
)
func init() {
glesysAPIUser = os.Getenv("GLESYS_API_USER")
glesysAPIKey = os.Getenv("GLESYS_API_KEY")
glesysDomain = os.Getenv("GLESYS_DOMAIN")
if len(glesysAPIUser) > 0 && len(glesysAPIKey) > 0 && len(glesysDomain) > 0 {
glesysLiveTest = true
}
}
func TestNewDNSProvider(t *testing.T) {
provider, err := NewDNSProvider()
if !glesysLiveTest {
assert.Error(t, err)
} else {
assert.NotNil(t, provider)
assert.NoError(t, err)
}
}
func TestDNSProvider_Present(t *testing.T) {
if !glesysLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProvider()
assert.NoError(t, err)
err = provider.Present(glesysDomain, "", "123d==")
assert.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
if !glesysLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProvider()
assert.NoError(t, err)
err = provider.CleanUp(glesysDomain, "", "123d==")
assert.NoError(t, err)
}

View file

@ -0,0 +1,107 @@
// Package lightsail implements a DNS provider for solving the DNS-01 challenge
// using AWS Lightsail DNS.
package lightsail
import (
"math/rand"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/client"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/lightsail"
"github.com/xenolf/lego/acme"
)
const (
maxRetries = 5
)
// DNSProvider implements the acme.ChallengeProvider interface
type DNSProvider struct {
client *lightsail.Lightsail
}
// customRetryer implements the client.Retryer interface by composing the
// DefaultRetryer. It controls the logic for retrying recoverable request
// errors (e.g. when rate limits are exceeded).
type customRetryer struct {
client.DefaultRetryer
}
// RetryRules overwrites the DefaultRetryer's method.
// It uses a basic exponential backoff algorithm that returns an initial
// delay of ~400ms with an upper limit of ~30 seconds which should prevent
// causing a high number of consecutive throttling errors.
// For reference: Route 53 enforces an account-wide(!) 5req/s query limit.
func (d customRetryer) RetryRules(r *request.Request) time.Duration {
retryCount := r.RetryCount
if retryCount > 7 {
retryCount = 7
}
delay := (1 << uint(retryCount)) * (rand.Intn(50) + 200)
return time.Duration(delay) * time.Millisecond
}
// NewDNSProvider returns a DNSProvider instance configured for the AWS
// Lightsail service.
//
// AWS Credentials are automatically detected in the following locations
// and prioritized in the following order:
// 1. Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
// [AWS_SESSION_TOKEN], [DNS_ZONE]
// 2. Shared credentials file (defaults to ~/.aws/credentials)
// 3. Amazon EC2 IAM role
//
// public hosted zone via the FQDN.
//
// See also: https://github.com/aws/aws-sdk-go/wiki/configuring-sdk
func NewDNSProvider() (*DNSProvider, error) {
r := customRetryer{}
r.NumMaxRetries = maxRetries
config := request.WithRetryer(aws.NewConfig(), r)
client := lightsail.New(session.New(config))
return &DNSProvider{
client: client,
}, nil
}
// Present creates a TXT record using the specified parameters
func (r *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"`
err := r.newTxtRecord(domain, fqdn, value)
return err
}
// CleanUp removes the TXT record matching the specified parameters
func (r *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
value = `"` + value + `"`
params := &lightsail.DeleteDomainEntryInput{
DomainName: aws.String(domain),
DomainEntry: &lightsail.DomainEntry{
Name: aws.String(fqdn),
Type: aws.String("TXT"),
Target: aws.String(value),
},
}
_, err := r.client.DeleteDomainEntry(params)
return err
}
func (r *DNSProvider) newTxtRecord(domain string, fqdn string, value string) error {
params := &lightsail.CreateDomainEntryInput{
DomainName: aws.String(domain),
DomainEntry: &lightsail.DomainEntry{
Name: aws.String(fqdn),
Target: aws.String(value),
Type: aws.String("TXT"),
},
}
_, err := r.client.CreateDomainEntry(params)
return err
}

View file

@ -0,0 +1,68 @@
package lightsail
import (
"fmt"
"os"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/lightsail"
)
func TestLightsailTTL(t *testing.T) {
m, err := testGetAndPreCheck()
if err != nil {
t.Skip(err.Error())
}
provider, err := NewDNSProvider()
if err != nil {
t.Fatalf("Fatal: %s", err.Error())
}
err = provider.Present(m["lightsailDomain"], "foo", "bar")
if err != nil {
t.Fatalf("Fatal: %s", err.Error())
}
// we need a separate Lightshail client here as the one in the DNS provider is
// unexported.
fqdn := "_acme-challenge." + m["lightsailDomain"]
svc := lightsail.New(session.New())
if err != nil {
provider.CleanUp(m["lightsailDomain"], "foo", "bar")
t.Fatalf("Fatal: %s", err.Error())
}
params := &lightsail.GetDomainInput{
DomainName: aws.String(m["lightsailDomain"]),
}
resp, err := svc.GetDomain(params)
if err != nil {
provider.CleanUp(m["lightsailDomain"], "foo", "bar")
t.Fatalf("Fatal: %s", err.Error())
}
entries := resp.Domain.DomainEntries
for _, entry := range entries {
if *entry.Type == "TXT" && *entry.Name == fqdn {
provider.CleanUp(m["lightsailDomain"], "foo", "bar")
return
}
}
provider.CleanUp(m["lightsailDomain"], "foo", "bar")
t.Fatalf("Could not find a TXT record for _acme-challenge.%s", m["lightsailDomain"])
}
func testGetAndPreCheck() (map[string]string, error) {
m := map[string]string{
"lightsailKey": os.Getenv("AWS_ACCESS_KEY_ID"),
"lightsailSecret": os.Getenv("AWS_SECRET_ACCESS_KEY"),
"lightsailDomain": os.Getenv("DNS_ZONE"),
}
for _, v := range m {
if v == "" {
return nil, fmt.Errorf("AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and R53_DOMAIN are needed to run this test")
}
}
return m, nil
}

View file

@ -0,0 +1,76 @@
package lightsail
import (
"net/http/httptest"
"os"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/lightsail"
"github.com/stretchr/testify/assert"
)
var (
lightsailSecret string
lightsailKey string
lightsailZone string
)
func init() {
lightsailKey = os.Getenv("AWS_ACCESS_KEY_ID")
lightsailSecret = os.Getenv("AWS_SECRET_ACCESS_KEY")
}
func restoreLightsailEnv() {
os.Setenv("AWS_ACCESS_KEY_ID", lightsailKey)
os.Setenv("AWS_SECRET_ACCESS_KEY", lightsailSecret)
os.Setenv("AWS_REGION", "us-east-1")
os.Setenv("AWS_HOSTED_ZONE_ID", lightsailZone)
}
func makeLightsailProvider(ts *httptest.Server) *DNSProvider {
config := &aws.Config{
Credentials: credentials.NewStaticCredentials("abc", "123", " "),
Endpoint: aws.String(ts.URL),
Region: aws.String("mock-region"),
MaxRetries: aws.Int(1),
}
client := lightsail.New(session.New(config))
return &DNSProvider{client: client}
}
func TestCredentialsFromEnv(t *testing.T) {
os.Setenv("AWS_ACCESS_KEY_ID", "123")
os.Setenv("AWS_SECRET_ACCESS_KEY", "123")
os.Setenv("AWS_REGION", "us-east-1")
config := &aws.Config{
CredentialsChainVerboseErrors: aws.Bool(true),
}
sess := session.New(config)
_, err := sess.Config.Credentials.Get()
assert.NoError(t, err, "Expected credentials to be set from environment")
restoreLightsailEnv()
}
func TestLightsailPresent(t *testing.T) {
mockResponses := MockResponseMap{
"/": MockResponse{StatusCode: 200, Body: ""},
}
ts := newMockServer(t, mockResponses)
defer ts.Close()
provider := makeLightsailProvider(ts)
domain := "example.com"
keyAuth := "123456d=="
err := provider.Present(domain, "", keyAuth)
assert.NoError(t, err, "Expected Present to return no error")
}

View file

@ -0,0 +1,38 @@
package lightsail
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// MockResponse represents a predefined response used by a mock server
type MockResponse struct {
StatusCode int
Body string
}
// MockResponseMap maps request paths to responses
type MockResponseMap map[string]MockResponse
func newMockServer(t *testing.T, responses MockResponseMap) *httptest.Server {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
resp, ok := responses[path]
if !ok {
msg := fmt.Sprintf("Requested path not found in response map: %s", path)
require.FailNow(t, msg)
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(resp.StatusCode)
w.Write([]byte(resp.Body))
}))
time.Sleep(100 * time.Millisecond)
return ts
}

View file

@ -0,0 +1,124 @@
// Package namedotcom implements a DNS provider for solving the DNS-01 challenge
// using Name.com's DNS service.
package namedotcom
import (
"fmt"
"os"
"strings"
"github.com/namedotcom/go/namecom"
"github.com/xenolf/lego/acme"
)
// DNSProvider is an implementation of the acme.ChallengeProvider interface.
type DNSProvider struct {
client *namecom.NameCom
}
// NewDNSProvider returns a DNSProvider instance configured for namedotcom.
// Credentials must be passed in the environment variables: NAMECOM_USERNAME and NAMECOM_API_TOKEN
func NewDNSProvider() (*DNSProvider, error) {
username := os.Getenv("NAMECOM_USERNAME")
apiToken := os.Getenv("NAMECOM_API_TOKEN")
server := os.Getenv("NAMECOM_SERVER")
return NewDNSProviderCredentials(username, apiToken, server)
}
// NewDNSProviderCredentials uses the supplied credentials to return a
// DNSProvider instance configured for namedotcom.
func NewDNSProviderCredentials(username, apiToken, server string) (*DNSProvider, error) {
if username == "" {
return nil, fmt.Errorf("Name.com Username is required")
}
if apiToken == "" {
return nil, fmt.Errorf("Name.com API token is required")
}
client := namecom.New(username, apiToken)
if server != "" {
client.Server = server
}
return &DNSProvider{client: client}, nil
}
// Present creates a TXT record to fulfil the dns-01 challenge.
func (c *DNSProvider) Present(domain, token, keyAuth string) error {
fqdn, value, ttl := acme.DNS01Record(domain, keyAuth)
request := &namecom.Record{
DomainName: domain,
Host: c.extractRecordName(fqdn, domain),
Type: "TXT",
TTL: uint32(ttl),
Answer: value,
}
_, err := c.client.CreateRecord(request)
if err != nil {
return fmt.Errorf("namedotcom API call failed: %v", err)
}
return nil
}
// CleanUp removes the TXT record matching the specified parameters.
func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error {
fqdn, _, _ := acme.DNS01Record(domain, keyAuth)
records, err := c.getRecords(domain)
if err != nil {
return err
}
for _, rec := range records {
if rec.Fqdn == fqdn && rec.Type == "TXT" {
request := &namecom.DeleteRecordRequest{
DomainName: domain,
ID: rec.ID,
}
_, err := c.client.DeleteRecord(request)
if err != nil {
return err
}
}
}
return nil
}
func (c *DNSProvider) getRecords(domain string) ([]*namecom.Record, error) {
var (
err error
records []*namecom.Record
response *namecom.ListRecordsResponse
)
request := &namecom.ListRecordsRequest{
DomainName: domain,
Page: 1,
}
for request.Page > 0 {
response, err = c.client.ListRecords(request)
if err != nil {
return nil, err
}
records = append(records, response.Records...)
request.Page = response.NextPage
}
return records, nil
}
func (c *DNSProvider) extractRecordName(fqdn, domain string) string {
name := acme.UnFqdn(fqdn)
if idx := strings.Index(name, "."+domain); idx != -1 {
return name[:idx]
}
return name
}

View file

@ -0,0 +1,58 @@
package namedotcom
import (
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
var (
namedotcomLiveTest bool
namedotcomUsername string
namedotcomAPIToken string
namedotcomDomain string
namedotcomServer string
)
func init() {
namedotcomUsername = os.Getenv("NAMEDOTCOM_USERNAME")
namedotcomAPIToken = os.Getenv("NAMEDOTCOM_API_TOKEN")
namedotcomDomain = os.Getenv("NAMEDOTCOM_DOMAIN")
namedotcomServer = os.Getenv("NAMEDOTCOM_SERVER")
if len(namedotcomAPIToken) > 0 && len(namedotcomUsername) > 0 && len(namedotcomDomain) > 0 {
namedotcomLiveTest = true
}
}
func TestLivenamedotcomPresent(t *testing.T) {
if !namedotcomLiveTest {
t.Skip("skipping live test")
}
provider, err := NewDNSProviderCredentials(namedotcomUsername, namedotcomAPIToken, namedotcomServer)
assert.NoError(t, err)
err = provider.Present(namedotcomDomain, "", "123d==")
assert.NoError(t, err)
}
//
// Cleanup
//
func TestLivenamedotcomCleanUp(t *testing.T) {
if !namedotcomLiveTest {
t.Skip("skipping live test")
}
time.Sleep(time.Second * 1)
provider, err := NewDNSProviderCredentials(namedotcomUsername, namedotcomAPIToken, namedotcomServer)
assert.NoError(t, err)
err = provider.CleanUp(namedotcomDomain, "", "123d==")
assert.NoError(t, err)
}

View file

@ -70,7 +70,11 @@ func NewDNSProvider() (*DNSProvider, error) {
r := customRetryer{}
r.NumMaxRetries = maxRetries
config := request.WithRetryer(aws.NewConfig(), r)
client := route53.New(session.New(config))
session, err := session.NewSessionWithOptions(session.Options{Config: *config})
if err != nil {
return nil, err
}
client := route53.New(session)
return &DNSProvider{
client: client,