diff --git a/cli.go b/cli.go index aed50127..faacf748 100644 --- a/cli.go +++ b/cli.go @@ -212,6 +212,7 @@ Here is an example bash command using the CloudFlare DNS provider: 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") diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 437aa499..d507be08 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -18,6 +18,7 @@ import ( "github.com/xenolf/lego/providers/dns/exoscale" "github.com/xenolf/lego/providers/dns/gandi" "github.com/xenolf/lego/providers/dns/gandiv5" + "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" @@ -62,6 +63,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error) provider, err = gandi.NewDNSProvider() case "gandiv5": provider, err = gandiv5.NewDNSProvider() + case "glesys": + provider, err = glesys.NewDNSProvider() case "gcloud": provider, err = googlecloud.NewDNSProvider() case "godaddy": 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) +}