diff --git a/cli.go b/cli.go index abdcf47d..4a1d1211 100644 --- a/cli.go +++ b/cli.go @@ -189,6 +189,7 @@ Here is an example bash command using the CloudFlare DNS provider: w := tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) fmt.Fprintln(w, "Valid providers and their associated credential environment variables:") fmt.Fprintln(w) + fmt.Fprintln(w, "\tauroradns:\tAURORA_USER_ID, AURORA_KEY, AURORA_ENDPOINT") fmt.Fprintln(w, "\tcloudflare:\tCLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY") fmt.Fprintln(w, "\tdigitalocean:\tDO_AUTH_TOKEN") fmt.Fprintln(w, "\tdnsimple:\tDNSIMPLE_EMAIL, DNSIMPLE_API_KEY") diff --git a/cli_handlers.go b/cli_handlers.go index 101deb0a..2e06b85f 100644 --- a/cli_handlers.go +++ b/cli_handlers.go @@ -15,6 +15,7 @@ import ( "github.com/urfave/cli" "github.com/xenolf/lego/acme" + "github.com/xenolf/lego/providers/dns/auroradns" "github.com/xenolf/lego/providers/dns/cloudflare" "github.com/xenolf/lego/providers/dns/digitalocean" "github.com/xenolf/lego/providers/dns/dnsimple" @@ -118,6 +119,8 @@ func setup(c *cli.Context) (*Configuration, *Account, *acme.Client) { var err error var provider acme.ChallengeProvider switch c.GlobalString("dns") { + case "auroradns": + provider, err = auroradns.NewDNSProvider() case "cloudflare": provider, err = cloudflare.NewDNSProvider() case "digitalocean": diff --git a/providers/dns/auroradns/auroradns.go b/providers/dns/auroradns/auroradns.go new file mode 100644 index 00000000..55b48f9b --- /dev/null +++ b/providers/dns/auroradns/auroradns.go @@ -0,0 +1,141 @@ +package auroradns + +import ( + "fmt" + "github.com/edeckers/auroradnsclient" + "github.com/edeckers/auroradnsclient/records" + "github.com/edeckers/auroradnsclient/zones" + "github.com/xenolf/lego/acme" + "os" + "sync" +) + +// DNSProvider describes a provider for AuroraDNS +type DNSProvider struct { + recordIDs map[string]string + recordIDsMu sync.Mutex + client *auroradnsclient.AuroraDNSClient +} + +// NewDNSProvider returns a DNSProvider instance configured for AuroraDNS. +// Credentials must be passed in the environment variables: AURORA_USER_ID +// and AURORA_KEY. +func NewDNSProvider() (*DNSProvider, error) { + userID := os.Getenv("AURORA_USER_ID") + key := os.Getenv("AURORA_KEY") + + endpoint := os.Getenv("AURORA_ENDPOINT") + if endpoint == "" { + endpoint = "https://api.auroradns.eu" + } + + return NewDNSProviderCredentials(endpoint, userID, key) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for AuroraDNS. +func NewDNSProviderCredentials(baseURL string, userID string, key string) (*DNSProvider, error) { + client, err := auroradnsclient.NewAuroraDNSClient(baseURL, userID, key) + if err != nil { + return nil, err + } + + return &DNSProvider{ + client: client, + recordIDs: make(map[string]string), + }, nil +} + +func (provider *DNSProvider) getZoneInformationByName(name string) (zones.ZoneRecord, error) { + zs, err := provider.client.GetZones() + + if err != nil { + return zones.ZoneRecord{}, err + } + + for _, element := range zs { + if element.Name == name { + return element, nil + } + } + + return zones.ZoneRecord{}, fmt.Errorf("Could not find Zone record") +} + +// Present creates a record with a secret +func (provider *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value, _ := acme.DNS01Record(domain, keyAuth) + + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Could not determine zone for domain: '%s'. %s", domain, err) + } + + // 1. Aurora will happily create the TXT record when it is provided a fqdn, + // but it will only appear in the control panel and will not be + // propagated to DNS servers. Extract and use subdomain instead. + // 2. A trailing dot in the fqdn will cause Aurora to add a trailing dot to + // the subdomain, resulting in _acme-challenge.. rather + // than _acme-challenge. + + subdomain := fqdn[0 : len(fqdn)-len(authZone)-1] + + authZone = acme.UnFqdn(authZone) + + zoneRecord, err := provider.getZoneInformationByName(authZone) + + reqData := + records.CreateRecordRequest{ + RecordType: "TXT", + Name: subdomain, + Content: value, + TTL: 300, + } + + respData, err := provider.client.CreateRecord(zoneRecord.ID, reqData) + if err != nil { + return fmt.Errorf("Could not create record: '%s'.", err) + } + + provider.recordIDsMu.Lock() + provider.recordIDs[fqdn] = respData.ID + provider.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes a given record that was generated by Present +func (provider *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _, _ := acme.DNS01Record(domain, keyAuth) + + provider.recordIDsMu.Lock() + recordID, ok := provider.recordIDs[fqdn] + provider.recordIDsMu.Unlock() + + if !ok { + return fmt.Errorf("Unknown recordID for '%s'", fqdn) + } + + authZone, err := acme.FindZoneByFqdn(acme.ToFqdn(domain), acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Could not determine zone for domain: '%s'. %s", domain, err) + } + + authZone = acme.UnFqdn(authZone) + + zoneRecord, err := provider.getZoneInformationByName(authZone) + if err != nil { + return err + } + + _, err = provider.client.RemoveRecord(zoneRecord.ID, recordID) + if err != nil { + return err + } + + provider.recordIDsMu.Lock() + delete(provider.recordIDs, fqdn) + provider.recordIDsMu.Unlock() + + return nil +} diff --git a/providers/dns/auroradns/auroradns_test.go b/providers/dns/auroradns/auroradns_test.go new file mode 100644 index 00000000..f4df7fa6 --- /dev/null +++ b/providers/dns/auroradns/auroradns_test.go @@ -0,0 +1,148 @@ +package auroradns + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" +) + +var fakeAuroraDNSUserId = "asdf1234" +var fakeAuroraDNSKey = "key" + +func TestAuroraDNSPresent(t *testing.T) { + var requestReceived bool + + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/zones" { + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `[{ + "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", + "name": "example.com" + }]`) + return + } + + requestReceived = true + + if got, want := r.Method, "POST"; got != want { + t.Errorf("Expected method to be '%s' but got '%s'", want, got) + } + + if got, want := r.URL.Path, "/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records"; got != want { + t.Errorf("Expected path to be '%s' but got '%s'", want, got) + } + + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Expected Content-Type to be '%s' but got '%s'", want, got) + } + + reqBody, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatalf("Error reading request body: %v", err) + } + + if got, want := string(reqBody), + `{"type":"TXT","name":"_acme-challenge","content":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":300}`; got != want { + + t.Errorf("Expected body data to be: `%s` but got `%s`", want, got) + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", + "type": "TXT", + "name": "_acme-challenge", + "ttl": 300 + }`) + })) + + defer mock.Close() + + auroraProvider, err := NewDNSProviderCredentials(mock.URL, fakeAuroraDNSUserId, fakeAuroraDNSKey) + if auroraProvider == nil { + t.Fatal("Expected non-nil AuroraDNS provider, but was nil") + } + + if err != nil { + t.Fatalf("Expected no error creating provider, but got: %v", err) + } + + err = auroraProvider.Present("example.com", "", "foobar") + if err != nil { + t.Fatalf("Expected no error creating TXT record, but got: %v", err) + } + + if !requestReceived { + t.Error("Expected request to be received by mock backend, but it wasn't") + } +} + +func TestAuroraDNSCleanUp(t *testing.T) { + var requestReceived bool + + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/zones" { + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `[{ + "id": "c56a4180-65aa-42ec-a945-5fd21dec0538", + "name": "example.com" + }]`) + return + } + + if r.Method == "POST" && r.URL.Path == "/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records" { + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "id": "ec56a4180-65aa-42ec-a945-5fd21dec0538", + "type": "TXT", + "name": "_acme-challenge", + "ttl": 300 + }`) + return + } + + requestReceived = true + + if got, want := r.Method, "DELETE"; got != want { + t.Errorf("Expected method to be '%s' but got '%s'", want, got) + } + + if got, want := r.URL.Path, + "/zones/c56a4180-65aa-42ec-a945-5fd21dec0538/records/ec56a4180-65aa-42ec-a945-5fd21dec0538"; got != want { + t.Errorf("Expected path to be '%s' but got '%s'", want, got) + } + + if got, want := r.Header.Get("Content-Type"), "application/json"; got != want { + t.Errorf("Expected Content-Type to be '%s' but got '%s'", want, got) + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{}`) + })) + defer mock.Close() + + auroraProvider, err := NewDNSProviderCredentials(mock.URL, fakeAuroraDNSUserId, fakeAuroraDNSKey) + if auroraProvider == nil { + t.Fatal("Expected non-nil AuroraDNS provider, but was nil") + } + + if err != nil { + t.Fatalf("Expected no error creating provider, but got: %v", err) + } + + err = auroraProvider.Present("example.com", "", "foobar") + if err != nil { + t.Fatalf("Expected no error creating TXT record, but got: %v", err) + } + + err = auroraProvider.CleanUp("example.com", "", "foobar") + if err != nil { + t.Fatalf("Expected no error removing TXT record, but got: %v", err) + } + + if !requestReceived { + t.Error("Expected request to be received by mock backend, but it wasn't") + } +}