diff --git a/cli.go b/cli.go index 84b70690..ff3800a9 100644 --- a/cli.go +++ b/cli.go @@ -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(` diff --git a/providers/dns/azure/azure.go b/providers/dns/azure/azure.go index cc15ca7e..ab622819 100644 --- a/providers/dns/azure/azure.go +++ b/providers/dns/azure/azure.go @@ -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 } diff --git a/providers/dns/azure/azure_test.go b/providers/dns/azure/azure_test.go index db55f578..3eeb10fc 100644 --- a/providers/dns/azure/azure_test.go +++ b/providers/dns/azure/azure_test.go @@ -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() } diff --git a/providers/dns/bluecat/bluecat.go b/providers/dns/bluecat/bluecat.go new file mode 100644 index 00000000..92b8a21d --- /dev/null +++ b/providers/dns/bluecat/bluecat.go @@ -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"` +} diff --git a/providers/dns/bluecat/bluecat_test.go b/providers/dns/bluecat/bluecat_test.go new file mode 100644 index 00000000..c1138ffc --- /dev/null +++ b/providers/dns/bluecat/bluecat_test.go @@ -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) +} diff --git a/providers/dns/cloudflare/cloudflare.go b/providers/dns/cloudflare/cloudflare.go index 84952238..d62b26f0 100644 --- a/providers/dns/cloudflare/cloudflare.go +++ b/providers/dns/cloudflare/cloudflare.go @@ -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{ diff --git a/providers/dns/cloudflare/cloudflare_test.go b/providers/dns/cloudflare/cloudflare_test.go index 19b5a40b..9fab1622 100644 --- a/providers/dns/cloudflare/cloudflare_test.go +++ b/providers/dns/cloudflare/cloudflare_test.go @@ -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") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 06235309..72ab0d8c 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -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) } diff --git a/providers/dns/dnsimple/dnsimple.go b/providers/dns/dnsimple/dnsimple.go index e3fea79e..df76a241 100644 --- a/providers/dns/dnsimple/dnsimple.go +++ b/providers/dns/dnsimple/dnsimple.go @@ -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 } diff --git a/providers/dns/duckdns/duckdns.go b/providers/dns/duckdns/duckdns.go new file mode 100644 index 00000000..6e2102a7 --- /dev/null +++ b/providers/dns/duckdns/duckdns.go @@ -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) +} diff --git a/providers/dns/duckdns/duckdns_test.go b/providers/dns/duckdns/duckdns_test.go new file mode 100644 index 00000000..f1afed4f --- /dev/null +++ b/providers/dns/duckdns/duckdns_test.go @@ -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) +} diff --git a/providers/dns/dyn/dyn.go b/providers/dns/dyn/dyn.go index 384bc850..277dffb9 100644 --- a/providers/dns/dyn/dyn.go +++ b/providers/dns/dyn/dyn.go @@ -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) diff --git a/providers/dns/exec/exec.go b/providers/dns/exec/exec.go new file mode 100644 index 00000000..ee140ae7 --- /dev/null +++ b/providers/dns/exec/exec.go @@ -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() +} diff --git a/providers/dns/fastdns/fastdns.go b/providers/dns/fastdns/fastdns.go new file mode 100644 index 00000000..dcbb93e5 --- /dev/null +++ b/providers/dns/fastdns/fastdns.go @@ -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() +} diff --git a/providers/dns/fastdns/fastdns_test.go b/providers/dns/fastdns/fastdns_test.go new file mode 100644 index 00000000..2c36f614 --- /dev/null +++ b/providers/dns/fastdns/fastdns_test.go @@ -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) +} diff --git a/providers/dns/glesys/glesys.go b/providers/dns/glesys/glesys.go new file mode 100644 index 00000000..36c6c00d --- /dev/null +++ b/providers/dns/glesys/glesys.go @@ -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 +} diff --git a/providers/dns/glesys/glesys_test.go b/providers/dns/glesys/glesys_test.go new file mode 100644 index 00000000..c10ba3a7 --- /dev/null +++ b/providers/dns/glesys/glesys_test.go @@ -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) +} diff --git a/providers/dns/lightsail/lightsail.go b/providers/dns/lightsail/lightsail.go new file mode 100644 index 00000000..a4d2efaf --- /dev/null +++ b/providers/dns/lightsail/lightsail.go @@ -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 +} diff --git a/providers/dns/lightsail/lightsail_integration_test.go b/providers/dns/lightsail/lightsail_integration_test.go new file mode 100644 index 00000000..ee6216ea --- /dev/null +++ b/providers/dns/lightsail/lightsail_integration_test.go @@ -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 +} diff --git a/providers/dns/lightsail/lightsail_test.go b/providers/dns/lightsail/lightsail_test.go new file mode 100644 index 00000000..d443da54 --- /dev/null +++ b/providers/dns/lightsail/lightsail_test.go @@ -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") +} diff --git a/providers/dns/lightsail/testutil_test.go b/providers/dns/lightsail/testutil_test.go new file mode 100644 index 00000000..11141216 --- /dev/null +++ b/providers/dns/lightsail/testutil_test.go @@ -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 +} diff --git a/providers/dns/namedotcom/namedotcom.go b/providers/dns/namedotcom/namedotcom.go new file mode 100644 index 00000000..2df4a597 --- /dev/null +++ b/providers/dns/namedotcom/namedotcom.go @@ -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 +} diff --git a/providers/dns/namedotcom/namedotcom_test.go b/providers/dns/namedotcom/namedotcom_test.go new file mode 100644 index 00000000..6d00a464 --- /dev/null +++ b/providers/dns/namedotcom/namedotcom_test.go @@ -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) +} diff --git a/providers/dns/route53/route53.go b/providers/dns/route53/route53.go index 934f0a2d..e16e12f0 100644 --- a/providers/dns/route53/route53.go +++ b/providers/dns/route53/route53.go @@ -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,