diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14fe6604..c12b1533 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,3 +128,4 @@ git push -u origin my-feature | VegaDNS | `vegadns` | [documentation](https://github.com/shupp/VegaDNS-API) | [Go client](https://github.com/OpenDNS/vegadns2client) | | Vultr | `vultr` | [documentation](https://www.vultr.com/api/#dns) | [Go client](https://github.com/JamesClonk/vultr) | | Vscale | `vscale` | [documentation](https://developers.vscale.io/documentation/api/v1/#api-Domains_Records) | - | +| Zone.ee | `zone` | [documentation](https://api.zone.eu/v2) | - | diff --git a/cmd/cmd_dnshelp.go b/cmd/cmd_dnshelp.go index c2dfc164..4592607b 100644 --- a/cmd/cmd_dnshelp.go +++ b/cmd/cmd_dnshelp.go @@ -81,6 +81,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tvegadns:\tSECRET_VEGADNS_KEY, SECRET_VEGADNS_SECRET, VEGADNS_URL") fmt.Fprintln(w, "\tvscale:\tVSCALE_API_TOKEN") fmt.Fprintln(w, "\tvultr:\tVULTR_API_KEY") + fmt.Fprintln(w, "\tzoneee:\tZONEEE_ENDPOINT, ZONEEE_API_USER, ZONEEE_API_KEY") fmt.Fprintln(w) fmt.Fprintln(w, "Additional configuration environment variables:") fmt.Fprintln(w) @@ -132,6 +133,7 @@ Here is an example bash command using the CloudFlare DNS provider: fmt.Fprintln(w, "\tvegadns:\tVEGADNS_POLLING_INTERVAL, VEGADNS_PROPAGATION_TIMEOUT, VEGADNS_TTL") fmt.Fprintln(w, "\tvscale:\tVSCALE_BASE_URL, VSCALE_TTL, VSCALE_PROPAGATION_TIMEOUT, VSCALE_POLLING_INTERVAL, VSCALE_HTTP_TIMEOUT") fmt.Fprintln(w, "\tvultr:\tVULTR_POLLING_INTERVAL, VULTR_PROPAGATION_TIMEOUT, VULTR_TTL, VULTR_HTTP_TIMEOUT") + fmt.Fprintln(w, "\tzoneee:\tZONEEE_POLLING_INTERVAL, ZONEEE_PROPAGATION_TIMEOUT, ZONEEE_HTTP_TIMEOUT") w.Flush() diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 3b661f65..ea8969b7 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -54,6 +54,7 @@ import ( "github.com/xenolf/lego/providers/dns/vegadns" "github.com/xenolf/lego/providers/dns/vscale" "github.com/xenolf/lego/providers/dns/vultr" + "github.com/xenolf/lego/providers/dns/zoneee" ) // NewDNSChallengeProviderByName Factory for DNS providers @@ -159,6 +160,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return vultr.NewDNSProvider() case "vscale": return vscale.NewDNSProvider() + case "zoneee": + return zoneee.NewDNSProvider() default: return nil, fmt.Errorf("unrecognised DNS provider: %s", name) } diff --git a/providers/dns/zoneee/client.go b/providers/dns/zoneee/client.go new file mode 100644 index 00000000..b48808b8 --- /dev/null +++ b/providers/dns/zoneee/client.go @@ -0,0 +1,132 @@ +package zoneee + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "path" +) + +const defaultEndpoint = "https://api.zone.eu/v2/dns/" + +type txtRecord struct { + // Identifier (identificator) + ID string `json:"id,omitempty"` + // Hostname + Name string `json:"name"` + // TXT content value + Destination string `json:"destination"` + // Can this record be deleted + Delete bool `json:"delete,omitempty"` + // Can this record be modified + Modify bool `json:"modify,omitempty"` + // API url to get this entity + ResourceURL string `json:"resource_url,omitempty"` +} + +func (d *DNSProvider) addTxtRecord(domain string, record txtRecord) ([]txtRecord, error) { + reqBody := &bytes.Buffer{} + if err := json.NewEncoder(reqBody).Encode(record); err != nil { + return nil, err + } + + req, err := d.makeRequest(http.MethodPost, path.Join(domain, "txt"), reqBody) + if err != nil { + return nil, err + } + + var resp []txtRecord + if err := d.sendRequest(req, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func (d *DNSProvider) getTxtRecords(domain string) ([]txtRecord, error) { + req, err := d.makeRequest(http.MethodGet, path.Join(domain, "txt"), nil) + if err != nil { + return nil, err + } + + var resp []txtRecord + if err := d.sendRequest(req, &resp); err != nil { + return nil, err + } + return resp, nil +} + +func (d *DNSProvider) removeTxtRecord(domain, id string) error { + req, err := d.makeRequest(http.MethodDelete, path.Join(domain, "txt", id), nil) + if err != nil { + return err + } + + return d.sendRequest(req, nil) +} + +func (d *DNSProvider) makeRequest(method, resource string, body io.Reader) (*http.Request, error) { + uri, err := d.config.Endpoint.Parse(resource) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(method, uri.String(), body) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(d.config.Username, d.config.APIKey) + + return req, nil +} + +func (d *DNSProvider) sendRequest(req *http.Request, result interface{}) error { + resp, err := d.config.HTTPClient.Do(req) + if err != nil { + return err + } + + if err = checkResponse(resp); err != nil { + return err + } + + defer resp.Body.Close() + + if result == nil { + return nil + } + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + err = json.Unmarshal(raw, result) + if err != nil { + return fmt.Errorf("unmarshaling %T error [status code=%d]: %v: %s", result, resp.StatusCode, err, string(raw)) + } + return err +} + +func checkResponse(resp *http.Response) error { + if resp.StatusCode < http.StatusBadRequest { + return nil + } + + if resp.Body == nil { + return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode) + } + + defer resp.Body.Close() + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("unable to read body: status code=%d, error=%v", resp.StatusCode, err) + } + + return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw)) +} diff --git a/providers/dns/zoneee/zoneee.go b/providers/dns/zoneee/zoneee.go new file mode 100644 index 00000000..0b136e30 --- /dev/null +++ b/providers/dns/zoneee/zoneee.go @@ -0,0 +1,134 @@ +// Package zoneee implements a DNS provider for solving the DNS-01 challenge through zone.ee. +package zoneee + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/xenolf/lego/challenge/dns01" + "github.com/xenolf/lego/platform/config/env" +) + +// Config is used to configure the creation of the DNSProvider +type Config struct { + Endpoint *url.URL + Username string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + endpoint, _ := url.Parse(defaultEndpoint) + + return &Config{ + Endpoint: endpoint, + // zone.ee can take up to 5min to propagate according to the support + PropagationTimeout: env.GetOrDefaultSecond("ZONEEE_PROPAGATION_TIMEOUT", 5*time.Minute), + PollingInterval: env.GetOrDefaultSecond("ZONEEE_POLLING_INTERVAL", 5*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("ZONEEE_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + +// DNSProvider describes a provider for acme-proxy +type DNSProvider struct { + config *Config +} + +// NewDNSProvider returns a DNSProvider instance. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("ZONEEE_API_USER", "ZONEEE_API_KEY") + if err != nil { + return nil, fmt.Errorf("zoneee: %v", err) + } + + rawEndpoint := env.GetOrDefaultString("ZONEEE_ENDPOINT", defaultEndpoint) + endpoint, err := url.Parse(rawEndpoint) + if err != nil { + return nil, fmt.Errorf("zoneee: %v", err) + } + + config := NewDefaultConfig() + config.Username = values["ZONEEE_API_USER"] + config.APIKey = values["ZONEEE_API_KEY"] + config.Endpoint = endpoint + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider . +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("zoneee: the configuration of the DNS provider is nil") + } + + if config.Username == "" { + return nil, fmt.Errorf("zoneee: credentials missing: username") + } + + if config.APIKey == "" { + return nil, fmt.Errorf("zoneee: credentials missing: API key") + } + + if config.Endpoint == nil { + return nil, errors.New("zoneee: the endpoint is missing") + } + + return &DNSProvider{config: config}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + record := txtRecord{ + Name: fqdn[:len(fqdn)-1], + Destination: value, + } + + _, err := d.addTxtRecord(domain, record) + if err != nil { + return fmt.Errorf("zoneee: %v", err) + } + return nil +} + +// CleanUp removes the TXT record previously created +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + _, value := dns01.GetRecord(domain, keyAuth) + + records, err := d.getTxtRecords(domain) + if err != nil { + return fmt.Errorf("zoneee: %v", err) + } + + var id string + for _, record := range records { + if record.Destination == value { + id = record.ID + } + } + + if id == "" { + return fmt.Errorf("zoneee: txt record does not exist for %v", value) + } + + if err = d.removeTxtRecord(domain, id); err != nil { + return fmt.Errorf("zoneee: %v", err) + } + + return nil +} diff --git a/providers/dns/zoneee/zoneee_test.go b/providers/dns/zoneee/zoneee_test.go new file mode 100644 index 00000000..c6a3c83f --- /dev/null +++ b/providers/dns/zoneee/zoneee_test.go @@ -0,0 +1,412 @@ +package zoneee + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/xenolf/lego/platform/tester" +) + +var envTest = tester.NewEnvTest("ZONEEE_ENDPOINT", "ZONEEE_API_USER", "ZONEEE_API_KEY"). + WithLiveTestRequirements("ZONEEE_API_USER", "ZONEEE_API_KEY"). + WithDomain("ZONEE_DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + "ZONEEE_API_USER": "123", + "ZONEEE_API_KEY": "456", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + "ZONEEE_API_USER": "", + "ZONEEE_API_KEY": "", + }, + expected: "zoneee: some credentials information are missing: ZONEEE_API_USER,ZONEEE_API_KEY", + }, + { + desc: "missing username", + envVars: map[string]string{ + "ZONEEE_API_USER": "", + "ZONEEE_API_KEY": "456", + }, + expected: "zoneee: some credentials information are missing: ZONEEE_API_USER", + }, + { + desc: "missing API key", + envVars: map[string]string{ + "ZONEEE_API_USER": "123", + "ZONEEE_API_KEY": "", + }, + expected: "zoneee: some credentials information are missing: ZONEEE_API_KEY", + }, + { + desc: "invalid URL", + envVars: map[string]string{ + "ZONEEE_API_USER": "123", + "ZONEEE_API_KEY": "456", + "ZONEEE_ENDPOINT": ":", + }, + expected: "zoneee: parse :: missing protocol scheme", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := NewDNSProvider() + + if len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiUser string + apiKey string + endpoint string + expected string + }{ + { + desc: "success", + apiKey: "123", + apiUser: "456", + }, + { + desc: "missing credentials", + expected: "zoneee: credentials missing: username", + }, + { + desc: "missing api key", + apiUser: "456", + expected: "zoneee: credentials missing: API key", + }, + { + desc: "missing username", + apiKey: "123", + expected: "zoneee: credentials missing: username", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.Username = test.apiUser + + if len(test.endpoint) > 0 { + config.Endpoint = mustParse(test.endpoint) + } + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestDNSProvider_Present(t *testing.T) { + domain := "prefix.example.com" + + testCases := []struct { + desc string + username string + apiKey string + handlers map[string]http.HandlerFunc + expectedError string + }{ + { + desc: "success", + username: "bar", + apiKey: "foo", + handlers: map[string]http.HandlerFunc{ + "/" + domain + "/txt": mockHandlerCreateRecord, + }, + }, + { + desc: "invalid auth", + username: "nope", + apiKey: "foo", + handlers: map[string]http.HandlerFunc{ + "/" + domain + "/txt": mockHandlerCreateRecord, + }, + expectedError: "zoneee: status code=401: Unauthorized\n", + }, + { + desc: "error", + username: "bar", + apiKey: "foo", + expectedError: "zoneee: status code=404: 404 page not found\n", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + for uri, handler := range test.handlers { + mux.HandleFunc(uri, handler) + } + + server := httptest.NewServer(mux) + + config := NewDefaultConfig() + config.Endpoint = mustParse(server.URL) + config.Username = test.username + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + err = p.Present(domain, "token", "key") + if test.expectedError == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.expectedError) + } + }) + } +} + +func TestDNSProvider_Cleanup(t *testing.T) { + domain := "prefix.example.com" + + testCases := []struct { + desc string + username string + apiKey string + handlers map[string]http.HandlerFunc + expectedError string + }{ + { + desc: "success", + username: "bar", + apiKey: "foo", + handlers: map[string]http.HandlerFunc{ + "/" + domain + "/txt": mockHandlerGetRecords([]txtRecord{{ + ID: "1234", + Name: domain, + Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", + Delete: true, + Modify: true, + }}), + "/" + domain + "/txt/1234": mockHandlerDeleteRecord, + }, + }, + { + desc: "no txt records", + username: "bar", + apiKey: "foo", + handlers: map[string]http.HandlerFunc{ + "/" + domain + "/txt": mockHandlerGetRecords([]txtRecord{}), + "/" + domain + "/txt/1234": mockHandlerDeleteRecord, + }, + expectedError: "zoneee: txt record does not exist for LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", + }, + { + desc: "invalid auth", + username: "nope", + apiKey: "foo", + handlers: map[string]http.HandlerFunc{ + "/" + domain + "/txt": mockHandlerGetRecords([]txtRecord{{ + ID: "1234", + Name: domain, + Destination: "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM", + Delete: true, + Modify: true, + }}), + "/" + domain + "/txt/1234": mockHandlerDeleteRecord, + }, + expectedError: "zoneee: status code=401: Unauthorized\n", + }, + { + desc: "error", + username: "bar", + apiKey: "foo", + expectedError: "zoneee: status code=404: 404 page not found\n", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + for uri, handler := range test.handlers { + mux.HandleFunc(uri, handler) + } + + server := httptest.NewServer(mux) + + config := NewDefaultConfig() + config.Endpoint = mustParse(server.URL) + config.Username = test.username + config.APIKey = test.apiKey + + p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + err = p.CleanUp(domain, "token", "key") + if test.expectedError == "" { + require.NoError(t, err) + } else { + require.EqualError(t, err, test.expectedError) + } + }) + } +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func mustParse(rawURL string) *url.URL { + uri, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return uri +} + +func mockHandlerCreateRecord(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + username, apiKey, ok := req.BasicAuth() + if username != "bar" || apiKey != "foo" || !ok { + rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "Please enter your username and API key.")) + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + record := txtRecord{} + err := json.NewDecoder(req.Body).Decode(&record) + if err != nil { + http.Error(rw, err.Error(), http.StatusBadRequest) + return + } + + record.ID = "1234" + record.Delete = true + record.Modify = true + record.ResourceURL = req.URL.String() + "/1234" + + bytes, err := json.Marshal([]txtRecord{record}) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if _, err = rw.Write(bytes); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func mockHandlerGetRecords(records []txtRecord) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + } + + username, apiKey, ok := req.BasicAuth() + if username != "bar" || apiKey != "foo" || !ok { + rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "Please enter your username and API key.")) + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + for _, value := range records { + if len(value.ResourceURL) == 0 { + value.ResourceURL = req.URL.String() + "/" + value.ID + } + } + + bytes, err := json.Marshal(records) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if _, err = rw.Write(bytes); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func mockHandlerDeleteRecord(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + username, apiKey, ok := req.BasicAuth() + if username != "bar" || apiKey != "foo" || !ok { + rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "Please enter your username and API key.")) + http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + rw.WriteHeader(http.StatusNoContent) +}