diff --git a/cli.go b/cli.go index 58567be9..c09b246d 100644 --- a/cli.go +++ b/cli.go @@ -140,7 +140,7 @@ func main() { }, cli.StringSliceFlag{ Name: "exclude, x", - Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"tls-sni-01\".", + Usage: "Explicitly disallow solvers by name from being used. Solvers: \"http-01\", \"tls-sni-01\", \"dns-01\",.", }, cli.StringFlag{ Name: "webroot", @@ -209,6 +209,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, "\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, "\tlinode:\tLINODE_API_KEY") fmt.Fprintln(w, "\tmanual:\tnone") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index d7530f78..50d40f10 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -15,8 +15,9 @@ import ( "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/godaddy" + "github.com/xenolf/lego/providers/dns/gandiv5" "github.com/xenolf/lego/providers/dns/googlecloud" + "github.com/xenolf/lego/providers/dns/godaddy" "github.com/xenolf/lego/providers/dns/linode" "github.com/xenolf/lego/providers/dns/namecheap" "github.com/xenolf/lego/providers/dns/ns1" @@ -53,6 +54,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) provider, err = exoscale.NewDNSProvider() case "gandi": provider, err = gandi.NewDNSProvider() + case "gandiv5": + provider, err = gandiv5.NewDNSProvider() case "gcloud": provider, err = googlecloud.NewDNSProvider() case "godaddy": diff --git a/providers/dns/gandiv5/gandiv5.go b/providers/dns/gandiv5/gandiv5.go new file mode 100644 index 00000000..86cc7bf3 --- /dev/null +++ b/providers/dns/gandiv5/gandiv5.go @@ -0,0 +1,203 @@ +// Package gandiv5 implements a DNS provider for solving the DNS-01 +// challenge using Gandi LiveDNS api. +package gandiv5 + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/xenolf/lego/acme" +) + +// Gandi API reference: http://doc.livedns.gandi.net/ + +var ( + // endpoint is the Gandi API endpoint used by Present and + // CleanUp. It is overridden during tests. + endpoint = "https://dns.api.gandi.net/api/v5" + // findZoneByFqdn determines the DNS zone of an fqdn. It is overridden + // during tests. + findZoneByFqdn = acme.FindZoneByFqdn +) + +// inProgressInfo contains information about an in-progress challenge +type inProgressInfo struct { + fieldName string + authZone string +} + +// DNSProvider is an implementation of the +// acme.ChallengeProviderTimeout interface that uses Gandi's LiveDNS +// API to manage TXT records for a domain. +type DNSProvider struct { + apiKey string + inProgressFQDNs map[string]inProgressInfo + inProgressMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance configured for Gandi. +// Credentials must be passed in the environment variable: GANDIV5_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + apiKey := os.Getenv("GANDIV5_API_KEY") + return NewDNSProviderCredentials(apiKey) +} + +// NewDNSProviderCredentials uses the supplied credentials to return a +// DNSProvider instance configured for Gandi. +func NewDNSProviderCredentials(apiKey string) (*DNSProvider, error) { + if apiKey == "" { + return nil, fmt.Errorf("Gandi DNS: No Gandi API Key given") + } + return &DNSProvider{ + apiKey: apiKey, + inProgressFQDNs: make(map[string]inProgressInfo), + }, 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 < 300 { + ttl = 300 // 300 is gandi minimum value for ttl + } + // find authZone + authZone, err := findZoneByFqdn(fqdn, acme.RecursiveNameservers) + if err != nil { + return fmt.Errorf("Gandi DNS: findZoneByFqdn failure: %v", err) + } + // determine name of TXT record + if !strings.HasSuffix( + strings.ToLower(fqdn), strings.ToLower("."+authZone)) { + return fmt.Errorf( + "Gandi 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 + err = d.addTXTRecord(acme.UnFqdn(authZone), name, value, ttl) + if err != nil { + return err + } + // save data necessary for CleanUp + d.inProgressFQDNs[fqdn] = inProgressInfo{ + authZone: authZone, + fieldName: name, + } + 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.inProgressFQDNs[fqdn]; !ok { + // if there is no cleanup information then just return + return nil + } + fieldName := d.inProgressFQDNs[fqdn].fieldName + authZone := d.inProgressFQDNs[fqdn].authZone + delete(d.inProgressFQDNs, fqdn) + // delete TXT record from authZone + err := d.deleteTXTRecord(acme.UnFqdn(authZone), fieldName) + 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 Gandi. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return 20 * time.Minute, 20 * time.Second +} + +// types for JSON method calls and parameters + +type addFieldRequest struct { + RRSetTTL int `json:"rrset_ttl"` + RRSetValues []string `json:"rrset_values"` +} + +type deleteFieldRequest struct { + Delete bool `json:"delete"` +} + +// types for JSON responses + +type responseStruct struct { + Message string `json:"message"` +} + +// POSTing/Marshalling/Unmarshalling + +func (d *DNSProvider) sendRequest(method string, resource string, payload interface{}) (*responseStruct, error) { + url := fmt.Sprintf("%s/%s", endpoint, 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.apiKey) > 0 { + req.Header.Set("X-Api-Key", 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("Gandi DNS: request failed with HTTP status code %d", resp.StatusCode) + } + var response responseStruct + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil && method != "DELETE" { + return nil, err + } + + return &response, nil +} + +// functions to perform API actions + +func (d *DNSProvider) addTXTRecord(domain string, name string, value string, ttl int) error { + target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name) + response, err := d.sendRequest("PUT", target, addFieldRequest{ + RRSetTTL: ttl, + RRSetValues: []string{value}, + }) + if response != nil { + fmt.Printf("Gandi DNS: %s\n", response.Message) + } + return err +} + +func (d *DNSProvider) deleteTXTRecord(domain string, name string) error { + target := fmt.Sprintf("domains/%s/records/%s/TXT", domain, name) + response, err := d.sendRequest("DELETE", target, deleteFieldRequest{ + Delete: true, + }) + if response != nil && response.Message == "" { + fmt.Printf("Gandi DNS: Zone record deleted\n") + } + return err +} diff --git a/providers/dns/gandiv5/gandiv5_test.go b/providers/dns/gandiv5/gandiv5_test.go new file mode 100644 index 00000000..56e63915 --- /dev/null +++ b/providers/dns/gandiv5/gandiv5_test.go @@ -0,0 +1,157 @@ +package gandiv5 + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + + "github.com/xenolf/lego/acme" +) + +// stagingServer is the Let's Encrypt staging server used by the live test +const stagingServer = "https://acme-staging.api.letsencrypt.org/directory" + +// user implements acme.User and is used by the live test +type user struct { + Email string + Registration *acme.RegistrationResource + key crypto.PrivateKey +} + +func (u *user) GetEmail() string { + return u.Email +} +func (u *user) GetRegistration() *acme.RegistrationResource { + return u.Registration +} +func (u *user) GetPrivateKey() crypto.PrivateKey { + return u.key +} + +// TestDNSProvider runs Present and CleanUp against a fake Gandi RPC +// Server, whose responses are predetermined for particular requests. +func TestDNSProvider(t *testing.T) { + fakeAPIKey := "123412341234123412341234" + fakeKeyAuth := "XXXX" + provider, err := NewDNSProviderCredentials(fakeAPIKey) + if err != nil { + t.Fatal(err) + } + regexpToken, err := regexp.Compile(`"rrset_values":\[".+"\]`) + if err != nil { + t.Fatal(err) + } + // start fake RPC server + fakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("Content-Type: application/json header not found") + } + req, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + req = regexpToken.ReplaceAllLiteral( + req, []byte(`"rrset_values":["TOKEN"]`)) + resp, ok := serverResponses[string(req)] + if !ok { + t.Fatalf("Server response for request not found") + } + _, err = io.Copy(w, strings.NewReader(resp)) + if err != nil { + t.Fatal(err) + } + })) + defer fakeServer.Close() + // define function to override findZoneByFqdn with + fakeFindZoneByFqdn := func(fqdn string, nameserver []string) (string, error) { + return "example.com.", nil + } + // override gandi endpoint and findZoneByFqdn function + savedEndpoint, savedFindZoneByFqdn := endpoint, findZoneByFqdn + defer func() { + endpoint, findZoneByFqdn = savedEndpoint, savedFindZoneByFqdn + }() + endpoint, findZoneByFqdn = fakeServer.URL, fakeFindZoneByFqdn + // run Present + err = provider.Present("abc.def.example.com", "", fakeKeyAuth) + if err != nil { + t.Fatal(err) + } + // run CleanUp + err = provider.CleanUp("abc.def.example.com", "", fakeKeyAuth) + if err != nil { + t.Fatal(err) + } +} + +// TestDNSProviderLive performs a live test to obtain a certificate +// using the Let's Encrypt staging server. It runs provided that both +// the environment variables GANDIV5_API_KEY and GANDI_TEST_DOMAIN are +// set. Otherwise the test is skipped. +// +// To complete this test, go test must be run with the -timeout=40m +// flag, since the default timeout of 10m is insufficient. +func TestDNSProviderLive(t *testing.T) { + apiKey := os.Getenv("GANDIV5_API_KEY") + domain := os.Getenv("GANDI_TEST_DOMAIN") + if apiKey == "" || domain == "" { + t.Skip("skipping live test") + } + // create a user. + const rsaKeySize = 2048 + privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) + if err != nil { + t.Fatal(err) + } + myUser := user{ + Email: "test@example.com", + key: privateKey, + } + // create a client using staging server + client, err := acme.NewClient(stagingServer, &myUser, acme.RSA2048) + if err != nil { + t.Fatal(err) + } + provider, err := NewDNSProviderCredentials(apiKey) + if err != nil { + t.Fatal(err) + } + err = client.SetChallengeProvider(acme.DNS01, provider) + if err != nil { + t.Fatal(err) + } + client.ExcludeChallenges([]acme.Challenge{acme.HTTP01, acme.TLSSNI01}) + // register and agree tos + reg, err := client.Register() + if err != nil { + t.Fatal(err) + } + myUser.Registration = reg + err = client.AgreeToTOS() + if err != nil { + t.Fatal(err) + } + // complete the challenge + bundle := false + _, failures := client.ObtainCertificate([]string{domain}, bundle, nil, false) + if len(failures) > 0 { + t.Fatal(failures) + } +} + +// serverResponses is the JSON Request->Response map used by the +// fake JSON server. +var serverResponses = map[string]string{ + // Present Request->Response (addTXTRecord) + `{"rrset_ttl":300,"rrset_values":["TOKEN"]}`: `{"message": "Zone Record Created"}`, + // CleanUp Request->Response (deleteTXTRecord) + `{"delete":true}`: ``, +}