diff --git a/cli.go b/cli.go index f9e414aa..6b2eec42 100644 --- a/cli.go +++ b/cli.go @@ -207,6 +207,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_API_KEY") fmt.Fprintln(w, "\tdnsmadeeasy:\tDNSMADEEASY_API_KEY, DNSMADEEASY_API_SECRET") + fmt.Fprintln(w, "\texoscale:\tEXOSCALE_API_KEY, EXOSCALE_API_SECRET, EXOSCALE_ENDPOINT") fmt.Fprintln(w, "\tgandi:\tGANDI_API_KEY") fmt.Fprintln(w, "\tgcloud:\tGCE_PROJECT") fmt.Fprintln(w, "\tlinode:\tLINODE_API_KEY") diff --git a/cli_handlers.go b/cli_handlers.go index bba0879a..39a449e0 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -22,6 +22,7 @@ import ( "github.com/xenolf/lego/providers/dns/dnsimple" "github.com/xenolf/lego/providers/dns/dnsmadeeasy" "github.com/xenolf/lego/providers/dns/dyn" + "github.com/xenolf/lego/providers/dns/exoscale" "github.com/xenolf/lego/providers/dns/gandi" "github.com/xenolf/lego/providers/dns/googlecloud" "github.com/xenolf/lego/providers/dns/linode" @@ -146,6 +147,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { provider, err = dnsimple.NewDNSProvider() case "dnsmadeeasy": provider, err = dnsmadeeasy.NewDNSProvider() + case "exoscale": + provider, err = exoscale.NewDNSProvider() case "dyn": provider, err = dyn.NewDNSProvider() case "gandi": diff --git a/providers/dns/exoscale/exoscale.go b/providers/dns/exoscale/exoscale.go new file mode 100644 index 00000000..3b6b58d0 --- /dev/null +++ b/providers/dns/exoscale/exoscale.go @@ -0,0 +1,132 @@ +// Package exoscale implements a DNS provider for solving the DNS-01 challenge +// using exoscale DNS. +package exoscale + +import ( + "errors" + "fmt" + "os" + + "github.com/pyr/egoscale/src/egoscale" + "github.com/xenolf/lego/acme" +) + +// DNSProvider is an implementation of the acme.ChallengeProvider interface. +type DNSProvider struct { + client *egoscale.Client +} + +// Credentials must be passed in the environment variables: +// EXOSCALE_API_KEY, EXOSCALE_API_SECRET, EXOSCALE_ENDPOINT. +func NewDNSProvider() (*DNSProvider, error) { + key := os.Getenv("EXOSCALE_API_KEY") + secret := os.Getenv("EXOSCALE_API_SECRET") + endpoint := os.Getenv("EXOSCALE_ENDPOINT") + return NewDNSProviderClient(key, secret, endpoint) +} + +// Uses the supplied parameters to return a DNSProvider instance +// configured for Exoscale. +func NewDNSProviderClient(key, secret, endpoint string) (*DNSProvider, error) { + if key == "" || secret == "" { + return nil, fmt.Errorf("Exoscale credentials missing") + } + if endpoint == "" { + endpoint = "https://api.exoscale.ch/dns" + } + + return &DNSProvider{ + client: egoscale.NewClient(endpoint, key, secret), + }, 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) + zone, recordName, err := c.FindZoneAndRecordName(fqdn, domain) + if err != nil { + return err + } + + recordId, err := c.FindExistingRecordId(zone, recordName) + if err != nil { + return err + } + + record := egoscale.DNSRecord{ + Name: recordName, + Ttl: ttl, + Content: value, + RecordType: "TXT", + } + + if recordId == 0 { + _, err := c.client.CreateRecord(zone, record) + if err != nil { + return errors.New("Error while creating DNS record: " + err.Error()) + } + } else { + record.Id = recordId + _, err := c.client.UpdateRecord(zone, record) + if err != nil { + return errors.New("Error while updating DNS record: " + err.Error()) + } + } + + return nil +} + +// CleanUp removes the record matching the specified parameters. +func (c *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + zone, recordName, err := c.FindZoneAndRecordName(fqdn, domain) + if err != nil { + return err + } + + recordId, err := c.FindExistingRecordId(zone, recordName) + if err != nil { + return err + } + + if recordId != 0 { + record := egoscale.DNSRecord{ + Id: recordId, + } + + err = c.client.DeleteRecord(zone, record) + if err != nil { + return errors.New("Error while deleting DNS record: " + err.Error()) + } + } + + return nil +} + +// Query Exoscale to find an existing record for this name. +// Returns nil if no record could be found +func (c *DNSProvider) FindExistingRecordId(zone, recordName string) (int64, error) { + responses, err := c.client.GetRecords(zone) + if err != nil { + return -1, errors.New("Error while retrievening DNS records: " + err.Error()) + } + for _, response := range responses { + if response.Record.Name == recordName { + return response.Record.Id, nil + } + } + return 0, nil +} + +// Extract DNS zone and DNS entry name +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 +} diff --git a/providers/dns/exoscale/exoscale_test.go b/providers/dns/exoscale/exoscale_test.go new file mode 100644 index 00000000..343dd56f --- /dev/null +++ b/providers/dns/exoscale/exoscale_test.go @@ -0,0 +1,103 @@ +package exoscale + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +var ( + exoscaleLiveTest bool + exoscaleAPIKey string + exoscaleAPISecret string + exoscaleDomain string +) + +func init() { + exoscaleAPISecret = os.Getenv("EXOSCALE_API_SECRET") + exoscaleAPIKey = os.Getenv("EXOSCALE_API_KEY") + exoscaleDomain = os.Getenv("EXOSCALE_DOMAIN") + if len(exoscaleAPIKey) > 0 && len(exoscaleAPISecret) > 0 && len(exoscaleDomain) > 0 { + exoscaleLiveTest = true + } +} + +func restoreExoscaleEnv() { + os.Setenv("EXOSCALE_API_KEY", exoscaleAPIKey) + os.Setenv("EXOSCALE_API_SECRET", exoscaleAPISecret) +} + +func TestNewDNSProviderValid(t *testing.T) { + os.Setenv("EXOSCALE_API_KEY", "") + os.Setenv("EXOSCALE_API_SECRET", "") + _, err := NewDNSProviderClient("example@example.com", "123", "") + assert.NoError(t, err) + restoreExoscaleEnv() +} +func TestNewDNSProviderValidEnv(t *testing.T) { + os.Setenv("EXOSCALE_API_KEY", "example@example.com") + os.Setenv("EXOSCALE_API_SECRET", "123") + _, err := NewDNSProvider() + assert.NoError(t, err) + restoreExoscaleEnv() +} + +func TestNewDNSProviderMissingCredErr(t *testing.T) { + os.Setenv("EXOSCALE_API_KEY", "") + os.Setenv("EXOSCALE_API_SECRET", "") + _, err := NewDNSProvider() + assert.EqualError(t, err, "Exoscale credentials missing") + restoreExoscaleEnv() +} + +func TestExtractRootRecordName(t *testing.T) { + provider, err := NewDNSProviderClient("example@example.com", "123", "") + 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("example@example.com", "123", "") + 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 TestLiveExoscalePresent(t *testing.T) { + if !exoscaleLiveTest { + t.Skip("skipping live test") + } + + provider, err := NewDNSProviderClient(exoscaleAPIKey, exoscaleAPISecret, "") + assert.NoError(t, err) + + err = provider.Present(exoscaleDomain, "", "123d==") + assert.NoError(t, err) + + // Present Twice to handle create / update + err = provider.Present(exoscaleDomain, "", "123d==") + assert.NoError(t, err) +} + +func TestLiveExoscaleCleanUp(t *testing.T) { + if !exoscaleLiveTest { + t.Skip("skipping live test") + } + + time.Sleep(time.Second * 1) + + provider, err := NewDNSProviderClient(exoscaleAPIKey, exoscaleAPISecret, "") + assert.NoError(t, err) + + err = provider.CleanUp(exoscaleDomain, "", "123d==") + assert.NoError(t, err) +}