diff --git a/cli.go b/cli.go index 2f9b76d0..dac2c50c 100644 --- a/cli.go +++ b/cli.go @@ -195,6 +195,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET") fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY") fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT") + fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY") fmt.Fprintln(w, "\tmanual:\tnone") fmt.Fprintln(w, "\tnamecheap:\tNAMECHEAP_API_USER, NAMECHEAP_API_KEY") fmt.Fprintln(w, "\trfc2136:\tRFC2136_TSIG_KEY, RFC2136_TSIG_SECRET,\n\t\tRFC2136_TSIG_ALGORITHM, RFC2136_NAMESERVER") diff --git a/cli_handlers.go b/cli_handlers.go index 0b4a7182..29a1166d 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -22,6 +22,7 @@ import ( "github.com/xenolf/lego/providers/dns/dyn" "github.com/xenolf/lego/providers/dns/gandi" "github.com/xenolf/lego/providers/dns/googlecloud" + "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/ovh" "github.com/xenolf/lego/providers/dns/pdns" @@ -130,6 +131,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { provider, err = gandi.NewDNSProvider() case "gcloud": provider, err = googlecloud.NewDNSProvider() + case "linode": + provider, err = linode.NewDNSProvider() case "manual": provider, err = acme.NewDNSProviderManual() case "namecheap": diff --git a/providers/dns/linode/linode.go b/providers/dns/linode/linode.go new file mode 100644 index 00000000..a91d2b48 --- /dev/null +++ b/providers/dns/linode/linode.go @@ -0,0 +1,131 @@ +// Package linode implements a DNS provider for solving the DNS-01 challenge +// using Linode DNS. +package linode + +import ( + "errors" + "os" + "strings" + "time" + + "github.com/timewasted/linode/dns" + "github.com/xenolf/lego/acme" +) + +const ( + dnsMinTTLSecs = 300 + dnsUpdateFreqMins = 15 + dnsUpdateFudgeSecs = 120 +) + +type hostedZoneInfo struct { + domainId int + resourceName string +} + +// DNSProvider implements the acme.ChallengeProvider interface. +type DNSProvider struct { + linode *dns.DNS +} + +// NewDNSProvider returns a DNSProvider instance configured for Linode. +// Credentials must be passed in the environment variable: LINODE_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("LINODE_API_KEY") + return NewDNSProviderCredentials(apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Linode. +func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { + if len(apiKey) == 0 { + return nil, errors.New("Linode credentials missing") + } + + return &DNSProvider{ + linode: dns.New(apiKey), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS +// propagation. Adjusting here to cope with spikes in propagation times. +func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { + // Since Linode only updates their zone files every X minutes, we need + // to figure out how many minutes we have to wait until we hit the next + // interval of X. We then wait another couple of minutes, just to be + // safe. Hopefully at some point during all of this, the record will + // have propagated throughout Linode's network. + minsRemaining := dnsUpdateFreqMins - (time.Now().Minute() % dnsUpdateFreqMins) + + timeout = (time.Duration(minsRemaining) * time.Minute) + + (dnsMinTTLSecs * time.Second) + + (dnsUpdateFudgeSecs * time.Second) + interval = 15 * time.Second + return +} + +// Present creates a TXT record using the specified parameters. +func (p *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zone, err := p.getHostedZoneInfo(fqdn) + if err != nil { + return err + } + + if _, err = p.linode.CreateDomainResourceTXT(zone.domainId, acme.UnFqdn(fqdn), value, 60); err != nil { + return err + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (p *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + zone, err := p.getHostedZoneInfo(fqdn) + if err != nil { + return err + } + + // Get all TXT records for the specified domain. + resources, err := p.linode.GetResourcesByType(zone.domainId, "TXT") + if err != nil { + return err + } + + // Remove the specified resource, if it exists. + for _, resource := range resources { + if resource.Name == zone.resourceName && resource.Target == value { + resp, err := p.linode.DeleteDomainResource(resource.DomainID, resource.ResourceID) + if err != nil { + return err + } + if resp.ResourceID != resource.ResourceID { + return errors.New("Error deleting resource: resource IDs do not match!") + } + break + } + } + + return nil +} + +func (p *DNSProvider) getHostedZoneInfo(fqdn string) (*hostedZoneInfo, error) { + // Lookup the zone that handles the specified FQDN. + authZone, err := acme.FindZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return nil, err + } + resourceName := strings.TrimSuffix(fqdn, "."+authZone) + + // Query the authority zone. + domain, err := p.linode.GetDomain(acme.UnFqdn(authZone)) + if err != nil { + return nil, err + } + + return &hostedZoneInfo{ + domainId: domain.DomainID, + resourceName: resourceName, + }, nil +} diff --git a/providers/dns/linode/linode_test.go b/providers/dns/linode/linode_test.go new file mode 100644 index 00000000..d9713a27 --- /dev/null +++ b/providers/dns/linode/linode_test.go @@ -0,0 +1,317 @@ +package linode + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/timewasted/linode" + "github.com/timewasted/linode/dns" +) + +type ( + LinodeResponse struct { + Action string `json:"ACTION"` + Data interface{} `json:"DATA"` + Errors []linode.ResponseError `json:"ERRORARRAY"` + } + MockResponse struct { + Response interface{} + Errors []linode.ResponseError + } + MockResponseMap map[string]MockResponse +) + +var ( + apiKey string + isTestLive bool +) + +func init() { + apiKey = os.Getenv("LINODE_API_KEY") + isTestLive = len(apiKey) != 0 +} + +func restoreEnv() { + os.Setenv("LINODE_API_KEY", apiKey) +} + +func newMockServer(t *testing.T, responses MockResponseMap) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Ensure that we support the requested action. + action := r.URL.Query().Get("api_action") + resp, ok := responses[action] + if !ok { + msg := fmt.Sprintf("Unsupported mock action: %s", action) + require.FailNow(t, msg) + } + + // Build the response that the server will return. + linodeResponse := LinodeResponse{ + Action: action, + Data: resp.Response, + Errors: resp.Errors, + } + rawResponse, err := json.Marshal(linodeResponse) + if err != nil { + msg := fmt.Sprintf("Failed to JSON encode response: %v", err) + require.FailNow(t, msg) + } + + // Send the response. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(rawResponse) + })) + + time.Sleep(100 * time.Millisecond) + return srv +} + +func TestNewDNSProviderWithEnv(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + _, err := NewDNSProvider() + assert.NoError(t, err) +} + +func TestNewDNSProviderWithoutEnv(t *testing.T) { + os.Setenv("LINODE_API_KEY", "") + defer restoreEnv() + _, err := NewDNSProvider() + assert.EqualError(t, err, "Linode credentials missing") +} + +func TestNewDNSProviderCredentialsWithKey(t *testing.T) { + _, err := NewDNSProviderCredentials("testing") + assert.NoError(t, err) +} + +func TestNewDNSProviderCredentialsWithoutKey(t *testing.T) { + _, err := NewDNSProviderCredentials("") + assert.EqualError(t, err, "Linode credentials missing") +} + +func TestDNSProvider_Present(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.create": MockResponse{ + Response: dns.ResourceResponse{ + ResourceID: 1234, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.Present(domain, "", keyAuth) + assert.NoError(t, err) +} + +func TestDNSProvider_PresentNoDomain(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: "foobar.com", + DomainID: 1234, + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.Present(domain, "", keyAuth) + assert.EqualError(t, err, "dns: requested domain not found") +} + +func TestDNSProvider_PresentCreateFailed(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.create": MockResponse{ + Response: nil, + Errors: []linode.ResponseError{ + linode.ResponseError{ + Code: 1234, + Message: "Failed to create domain resource", + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.Present(domain, "", keyAuth) + assert.EqualError(t, err, "Failed to create domain resource") +} + +func TestDNSProvider_PresentLive(t *testing.T) { + if !isTestLive { + t.Skip("Skipping live test") + } +} + +func TestDNSProvider_CleanUp(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.list": MockResponse{ + Response: []dns.Resource{ + dns.Resource{ + DomainID: 1234, + Name: "_acme-challenge", + ResourceID: 1234, + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }, + }, + }, + "domain.resource.delete": MockResponse{ + Response: dns.ResourceResponse{ + ResourceID: 1234, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.CleanUp(domain, "", keyAuth) + assert.NoError(t, err) +} + +func TestDNSProvider_CleanUpNoDomain(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: "foobar.com", + DomainID: 1234, + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.CleanUp(domain, "", keyAuth) + assert.EqualError(t, err, "dns: requested domain not found") +} + +func TestDNSProvider_CleanUpDeleteFailed(t *testing.T) { + os.Setenv("LINODE_API_KEY", "testing") + defer restoreEnv() + p, err := NewDNSProvider() + assert.NoError(t, err) + + domain := "example.com" + keyAuth := "dGVzdGluZw==" + mockResponses := MockResponseMap{ + "domain.list": MockResponse{ + Response: []dns.Domain{ + dns.Domain{ + Domain: domain, + DomainID: 1234, + }, + }, + }, + "domain.resource.list": MockResponse{ + Response: []dns.Resource{ + dns.Resource{ + DomainID: 1234, + Name: "_acme-challenge", + ResourceID: 1234, + Target: "ElbOJKOkFWiZLQeoxf-wb3IpOsQCdvoM0y_wn0TEkxM", + Type: "TXT", + }, + }, + }, + "domain.resource.delete": MockResponse{ + Response: nil, + Errors: []linode.ResponseError{ + linode.ResponseError{ + Code: 1234, + Message: "Failed to delete domain resource", + }, + }, + }, + } + mockSrv := newMockServer(t, mockResponses) + defer mockSrv.Close() + p.linode.ToLinode().SetEndpoint(mockSrv.URL) + + err = p.CleanUp(domain, "", keyAuth) + assert.EqualError(t, err, "Failed to delete domain resource") +} + +func TestDNSProvider_CleanUpLive(t *testing.T) { + if !isTestLive { + t.Skip("Skipping live test") + } +}