diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index a4e5e1f2..c1e111f8 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -975,7 +975,8 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`Credentials:`) - ew.writeln(` - "JOKER_API_KEY": API key`) + ew.writeln(` - "JOKER_API_KEY": API key (only with DMAPI mode)`) + ew.writeln(` - "JOKER_API_MODE": 'DMAPI' or 'SVC'. DMAPI is for resellers accounts. (Default: DMAPI)`) ew.writeln(` - "JOKER_PASSWORD": Joker.com password`) ew.writeln(` - "JOKER_USERNAME": Joker.com username (email address)`) ew.writeln() diff --git a/docs/content/dns/zz_gen_joker.md b/docs/content/dns/zz_gen_joker.md index 19476320..6582cea2 100644 --- a/docs/content/dns/zz_gen_joker.md +++ b/docs/content/dns/zz_gen_joker.md @@ -21,10 +21,19 @@ Configuration for [Joker](https://joker.com). Here is an example bash command using the Joker provider: ```bash +# SVC +JOKER_API_MODE=SVC \ JOKER_USERNAME= \ JOKER_PASSWORD= \ lego --dns joker --domains my.domain.com --email my@email.com run -# or + +# DMAPI +JOKER_API_MODE=DMAPI \ +JOKER_USERNAME= \ +JOKER_PASSWORD= \ +lego --dns joker --domains my.domain.com --email my@email.com run +## or +JOKER_API_MODE=DMAPI \ JOKER_API_KEY= \ lego --dns joker --domains my.domain.com --email my@email.com run ``` @@ -36,7 +45,8 @@ lego --dns joker --domains my.domain.com --email my@email.com run | Environment Variable Name | Description | |-----------------------|-------------| -| `JOKER_API_KEY` | API key | +| `JOKER_API_KEY` | API key (only with DMAPI mode) | +| `JOKER_API_MODE` | 'DMAPI' or 'SVC'. DMAPI is for resellers accounts. (Default: DMAPI) | | `JOKER_PASSWORD` | Joker.com password | | `JOKER_USERNAME` | Joker.com username (email address) | diff --git a/providers/dns/joker/client.go b/providers/dns/joker/internal/dmapi/client.go similarity index 55% rename from providers/dns/joker/client.go rename to providers/dns/joker/internal/dmapi/client.go index 3a6e894e..c02cf197 100644 --- a/providers/dns/joker/client.go +++ b/providers/dns/joker/internal/dmapi/client.go @@ -1,4 +1,6 @@ -package joker +// Package dmapi Client for DMAPI joker.com. +// https://joker.com/faq/category/39/22-dmapi.html +package dmapi import ( "errors" @@ -6,6 +8,7 @@ import ( "io/ioutil" "net/http" "net/url" + "path" "strconv" "strings" @@ -15,8 +18,8 @@ import ( const defaultBaseURL = "https://dmapi.joker.com/request/" -// Joker DMAPI Response. -type response struct { +// Response Joker DMAPI Response. +type Response struct { Headers url.Values Body string StatusCode int @@ -24,9 +27,143 @@ type response struct { AuthSid string } +type AuthInfo struct { + APIKey string + Username string + Password string + authSid string +} + +// Client a DMAPI Client. +type Client struct { + HTTPClient *http.Client + BaseURL string + + Debug bool + + auth AuthInfo +} + +// NewClient creates a new DMAPI Client. +func NewClient(auth AuthInfo) *Client { + return &Client{ + HTTPClient: http.DefaultClient, + BaseURL: defaultBaseURL, + Debug: false, + auth: auth, + } +} + +// Login performs a login to Joker's DMAPI. +func (c *Client) Login() (*Response, error) { + if c.auth.authSid != "" { + // already logged in + return nil, nil + } + + var values url.Values + switch { + case c.auth.Username != "" && c.auth.Password != "": + values = url.Values{ + "username": {c.auth.Username}, + "password": {c.auth.Password}, + } + case c.auth.APIKey != "": + values = url.Values{"api-key": {c.auth.APIKey}} + default: + return nil, errors.New("no username and password or api-key") + } + + response, err := c.postRequest("login", values) + if err != nil { + return response, err + } + + if response == nil { + return nil, errors.New("login returned nil response") + } + + if response.AuthSid == "" { + return response, errors.New("login did not return valid Auth-Sid") + } + + c.auth.authSid = response.AuthSid + + return response, nil +} + +// Logout closes authenticated session with Joker's DMAPI. +func (c *Client) Logout() (*Response, error) { + if c.auth.authSid == "" { + return nil, errors.New("already logged out") + } + + response, err := c.postRequest("logout", url.Values{}) + if err == nil { + c.auth.authSid = "" + } + return response, err +} + +// GetZone returns content of DNS zone for domain. +func (c *Client) GetZone(domain string) (*Response, error) { + if c.auth.authSid == "" { + return nil, errors.New("must be logged in to get zone") + } + + return c.postRequest("dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}}) +} + +// PutZone uploads DNS zone to Joker DMAPI. +func (c *Client) PutZone(domain, zone string) (*Response, error) { + if c.auth.authSid == "" { + return nil, errors.New("must be logged in to put zone") + } + + return c.postRequest("dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}}) +} + +// postRequest performs actual HTTP request. +func (c *Client) postRequest(cmd string, data url.Values) (*Response, error) { + baseURL, err := url.Parse(c.BaseURL) + if err != nil { + return nil, err + } + + endpoint, err := baseURL.Parse(path.Join(baseURL.Path, cmd)) + if err != nil { + return nil, err + } + + if c.auth.authSid != "" { + data.Set("auth-sid", c.auth.authSid) + } + + if c.Debug { + log.Infof("postRequest:\n\tURL: %q\n\tData: %v", endpoint.String(), data) + } + + resp, err := c.HTTPClient.PostForm(endpoint.String(), data) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP error %d [%s]: %v", resp.StatusCode, http.StatusText(resp.StatusCode), string(body)) + } + + return parseResponse(string(body)), nil +} + // parseResponse parses HTTP response body. -func parseResponse(message string) *response { - r := &response{Headers: url.Values{}, StatusCode: -1} +func parseResponse(message string) *Response { + r := &Response{Headers: url.Values{}, StatusCode: -1} parts := strings.SplitN(message, "\n\n", 2) @@ -64,105 +201,6 @@ func parseResponse(message string) *response { return r } -// login performs a login to Joker's DMAPI. -func (d *DNSProvider) login() (*response, error) { - if d.config.AuthSid != "" { - // already logged in - return nil, nil - } - - var values url.Values - switch { - case d.config.Username != "" && d.config.Password != "": - values = url.Values{ - "username": {d.config.Username}, - "password": {d.config.Password}, - } - case d.config.APIKey != "": - values = url.Values{"api-key": {d.config.APIKey}} - default: - return nil, errors.New("no username and password or api-key") - } - - response, err := d.postRequest("login", values) - if err != nil { - return response, err - } - - if response == nil { - return nil, errors.New("login returned nil response") - } - - if response.AuthSid == "" { - return response, errors.New("login did not return valid Auth-Sid") - } - - d.config.AuthSid = response.AuthSid - - return response, nil -} - -// logout closes authenticated session with Joker's DMAPI. -func (d *DNSProvider) logout() (*response, error) { - if d.config.AuthSid == "" { - return nil, errors.New("already logged out") - } - - response, err := d.postRequest("logout", url.Values{}) - if err == nil { - d.config.AuthSid = "" - } - return response, err -} - -// getZone returns content of DNS zone for domain. -func (d *DNSProvider) getZone(domain string) (*response, error) { - if d.config.AuthSid == "" { - return nil, errors.New("must be logged in to get zone") - } - - return d.postRequest("dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}}) -} - -// putZone uploads DNS zone to Joker DMAPI. -func (d *DNSProvider) putZone(domain, zone string) (*response, error) { - if d.config.AuthSid == "" { - return nil, errors.New("must be logged in to put zone") - } - - return d.postRequest("dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}}) -} - -// postRequest performs actual HTTP request. -func (d *DNSProvider) postRequest(cmd string, data url.Values) (*response, error) { - uri := d.config.BaseURL + cmd - - if d.config.AuthSid != "" { - data.Set("auth-sid", d.config.AuthSid) - } - - if d.config.Debug { - log.Infof("postRequest:\n\tURL: %q\n\tData: %v", uri, data) - } - - resp, err := d.config.HTTPClient.PostForm(uri, data) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("HTTP error %d [%s]: %v", resp.StatusCode, http.StatusText(resp.StatusCode), string(body)) - } - - return parseResponse(string(body)), nil -} - // Temporary workaround, until it get fixed on API side. func fixTxtLines(line string) string { fields := strings.Fields(line) @@ -179,8 +217,8 @@ func fixTxtLines(line string) string { return strings.Join(fields, " ") } -// removeTxtEntryFromZone clean-ups all TXT records with given name. -func removeTxtEntryFromZone(zone, relative string) (string, bool) { +// RemoveTxtEntryFromZone clean-ups all TXT records with given name. +func RemoveTxtEntryFromZone(zone, relative string) (string, bool) { prefix := fmt.Sprintf("%s TXT 0 ", relative) modified := false @@ -196,8 +234,8 @@ func removeTxtEntryFromZone(zone, relative string) (string, bool) { return strings.TrimSpace(strings.Join(zoneEntries, "\n")), modified } -// addTxtEntryToZone returns DNS zone with added TXT record. -func addTxtEntryToZone(zone, relative, value string, ttl int) string { +// AddTxtEntryToZone returns DNS zone with added TXT record. +func AddTxtEntryToZone(zone, relative, value string, ttl int) string { var zoneEntries []string for _, line := range strings.Split(zone, "\n") { diff --git a/providers/dns/joker/client_test.go b/providers/dns/joker/internal/dmapi/client_test.go similarity index 87% rename from providers/dns/joker/client_test.go rename to providers/dns/joker/internal/dmapi/client_test.go index a1b0786e..d67984bb 100644 --- a/providers/dns/joker/client_test.go +++ b/providers/dns/joker/internal/dmapi/client_test.go @@ -1,4 +1,4 @@ -package joker +package dmapi import ( "io" @@ -23,10 +23,13 @@ const ( serverErrorUsername = "error" ) -func setup() (*http.ServeMux, *httptest.Server) { +func setup(t *testing.T) (*http.ServeMux, string) { mux := http.NewServeMux() + server := httptest.NewServer(mux) - return mux, server + t.Cleanup(server.Close) + + return mux, server.URL } func TestDNSProvider_login_api_key(t *testing.T) { @@ -63,11 +66,10 @@ func TestDNSProvider_login_api_key(t *testing.T) { }, } - mux, server := setup() - defer server.Close() + mux, serverURL := setup(t) mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "POST", r.Method) + require.Equal(t, http.MethodPost, r.Method) switch r.FormValue("api-key") { case correctAPIKey: @@ -83,15 +85,10 @@ func TestDNSProvider_login_api_key(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - config := NewDefaultConfig() - config.BaseURL = server.URL - config.APIKey = test.apiKey + client := NewClient(AuthInfo{APIKey: test.apiKey}) + client.BaseURL = serverURL - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - require.NotNil(t, p) - - response, err := p.login() + response, err := client.Login() if test.expectedError { require.Error(t, err) } else { @@ -144,11 +141,10 @@ func TestDNSProvider_login_username(t *testing.T) { }, } - mux, server := setup() - defer server.Close() + mux, serverURL := setup(t) mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "POST", r.Method) + require.Equal(t, http.MethodPost, r.Method) switch r.FormValue("username") { case correctUsername: @@ -164,16 +160,10 @@ func TestDNSProvider_login_username(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - config := NewDefaultConfig() - config.BaseURL = server.URL - config.Username = test.username - config.Password = test.password + client := NewClient(AuthInfo{Username: test.username, Password: test.password}) + client.BaseURL = serverURL - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - require.NotNil(t, p) - - response, err := p.login() + response, err := client.Login() if test.expectedError { require.Error(t, err) } else { @@ -215,11 +205,10 @@ func TestDNSProvider_logout(t *testing.T) { }, } - mux, server := setup() - defer server.Close() + mux, serverURL := setup(t) mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "POST", r.Method) + require.Equal(t, http.MethodPost, r.Method) switch r.FormValue("auth-sid") { case correctAPIKey: @@ -233,16 +222,10 @@ func TestDNSProvider_logout(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - config := NewDefaultConfig() - config.BaseURL = server.URL - config.APIKey = "12345" - config.AuthSid = test.authSid + client := NewClient(AuthInfo{APIKey: "12345", authSid: test.authSid}) + client.BaseURL = serverURL - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - require.NotNil(t, p) - - response, err := p.logout() + response, err := client.Logout() if test.expectedError { require.Error(t, err) } else { @@ -291,11 +274,10 @@ func TestDNSProvider_getZone(t *testing.T) { }, } - mux, server := setup() - defer server.Close() + mux, serverURL := setup(t) mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, "POST", r.Method) + require.Equal(t, http.MethodPost, r.Method) authSid := r.FormValue("auth-sid") domain := r.FormValue("domain") @@ -312,16 +294,10 @@ func TestDNSProvider_getZone(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - config := NewDefaultConfig() - config.BaseURL = server.URL - config.APIKey = "12345" - config.AuthSid = test.authSid + client := NewClient(AuthInfo{APIKey: "12345", authSid: test.authSid}) + client.BaseURL = serverURL - p, err := NewDNSProviderConfig(config) - require.NoError(t, err) - require.NotNil(t, p) - - response, err := p.getZone(test.domain) + response, err := client.GetZone(test.domain) if test.expectedError { require.Error(t, err) } else { @@ -338,12 +314,12 @@ func Test_parseResponse(t *testing.T) { testCases := []struct { desc string input string - expected *response + expected *Response }{ { desc: "Empty response", input: "", - expected: &response{ + expected: &Response{ Headers: url.Values{}, StatusCode: -1, }, @@ -351,7 +327,7 @@ func Test_parseResponse(t *testing.T) { { desc: "No headers, just body", input: "\n\nTest body", - expected: &response{ + expected: &Response{ Headers: url.Values{}, Body: "Test body", StatusCode: -1, @@ -360,7 +336,7 @@ func Test_parseResponse(t *testing.T) { { desc: "Headers and body", input: "Test-Header: value\n\nTest body", - expected: &response{ + expected: &Response{ Headers: url.Values{"Test-Header": {"value"}}, Body: "Test body", StatusCode: -1, @@ -369,7 +345,7 @@ func Test_parseResponse(t *testing.T) { { desc: "Headers and body + Auth-Sid", input: "Test-Header: value\nAuth-Sid: 123\n\nTest body", - expected: &response{ + expected: &Response{ Headers: url.Values{"Test-Header": {"value"}, "Auth-Sid": {"123"}}, Body: "Test body", StatusCode: -1, @@ -379,7 +355,7 @@ func Test_parseResponse(t *testing.T) { { desc: "Headers and body + Status-Text", input: "Test-Header: value\nStatus-Text: OK\n\nTest body", - expected: &response{ + expected: &Response{ Headers: url.Values{"Test-Header": {"value"}, "Status-Text": {"OK"}}, Body: "Test body", StatusText: "OK", @@ -389,7 +365,7 @@ func Test_parseResponse(t *testing.T) { { desc: "Headers and body + Status-Code", input: "Test-Header: value\nStatus-Code: 2020\n\nTest body", - expected: &response{ + expected: &Response{ Headers: url.Values{"Test-Header": {"value"}, "Status-Code": {"2020"}}, Body: "Test body", StatusCode: 2020, @@ -453,7 +429,7 @@ func Test_removeTxtEntryFromZone(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - zone, modified := removeTxtEntryFromZone(test.input, "_acme-challenge") + zone, modified := RemoveTxtEntryFromZone(test.input, "_acme-challenge") assert.Equal(t, zone, test.expected) assert.Equal(t, modified, test.modified) }) @@ -485,7 +461,7 @@ func Test_addTxtEntryToZone(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - zone := addTxtEntryToZone(test.input, "_acme-challenge", "test", 120) + zone := AddTxtEntryToZone(test.input, "_acme-challenge", "test", 120) assert.Equal(t, zone, test.expected) }) } diff --git a/providers/dns/joker/internal/svc/client.go b/providers/dns/joker/internal/svc/client.go new file mode 100644 index 00000000..2790377f --- /dev/null +++ b/providers/dns/joker/internal/svc/client.go @@ -0,0 +1,72 @@ +// Package svc Client for the SVC API. +// https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html +package svc + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + + querystring "github.com/google/go-querystring/query" +) + +const defaultBaseURL = "https://svc.joker.com/nic/replace" + +type request struct { + Username string `url:"username"` + Password string `url:"password"` + Zone string `url:"zone"` + Label string `url:"label"` + Type string `url:"type"` + Value string `url:"value"` +} + +type Client struct { + HTTPClient *http.Client + BaseURL string + + username string + password string +} + +func NewClient(username, password string) *Client { + return &Client{ + HTTPClient: http.DefaultClient, + BaseURL: defaultBaseURL, + username: username, + password: password, + } +} + +func (c *Client) Send(zone, label, value string) error { + req := request{ + Username: c.username, + Password: c.password, + Zone: zone, + Label: label, + Type: "TXT", + Value: value, + } + + v, err := querystring.Values(req) + if err != nil { + return err + } + + resp, err := c.HTTPClient.PostForm(c.BaseURL, v) + if err != nil { + return err + } + + all, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusOK && strings.HasPrefix(string(all), "OK") { + return nil + } + + return fmt.Errorf("error: %d: %s", resp.StatusCode, string(all)) +} diff --git a/providers/dns/joker/internal/svc/client_test.go b/providers/dns/joker/internal/svc/client_test.go new file mode 100644 index 00000000..542d3b5c --- /dev/null +++ b/providers/dns/joker/internal/svc/client_test.go @@ -0,0 +1,79 @@ +package svc + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClient_Send(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + all, _ := ioutil.ReadAll(req.Body) + + if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=123&zone=example.com" { + http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest) + return + } + + _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted")) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + }) + + client := NewClient("test", "secret") + client.BaseURL = server.URL + + zone := "example.com" + label := "_acme-challenge" + value := "123" + + err := client.Send(zone, label, value) + require.NoError(t, err) +} + +func TestClient_Send_empty(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + all, _ := ioutil.ReadAll(req.Body) + + if string(all) != "label=_acme-challenge&password=secret&type=TXT&username=test&value=&zone=example.com" { + http.Error(rw, fmt.Sprintf("invalid request: %q", string(all)), http.StatusBadRequest) + return + } + + _, err := rw.Write([]byte("OK: 1 inserted, 0 deleted")) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + } + }) + + client := NewClient("test", "secret") + client.BaseURL = server.URL + + zone := "example.com" + label := "_acme-challenge" + value := "" + + err := client.Send(zone, label, value) + require.NoError(t, err) +} diff --git a/providers/dns/joker/joker.go b/providers/dns/joker/joker.go index 9e786d25..d385b08d 100644 --- a/providers/dns/joker/joker.go +++ b/providers/dns/joker/joker.go @@ -1,15 +1,13 @@ -// Package joker implements a DNS provider for solving the DNS-01 challenge using joker.com DMAPI. +// Package joker implements a DNS provider for solving the DNS-01 challenge using joker.com. package joker import ( - "errors" - "fmt" "net/http" - "strings" + "os" "time" + "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" - "github.com/go-acme/lego/v4/log" "github.com/go-acme/lego/v4/platform/config/env" ) @@ -21,178 +19,64 @@ const ( EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvDebug = envNamespace + "DEBUG" + EnvMode = envNamespace + "API_MODE" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +const ( + modeDMAPI = "DMAPI" + modeSVC = "SVC" +) + // Config is used to configure the creation of the DNSProvider. type Config struct { Debug bool - BaseURL string APIKey string Username string Password string + APIMode string PropagationTimeout time.Duration PollingInterval time.Duration + SequenceInterval time.Duration TTL int HTTPClient *http.Client - AuthSid string } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - BaseURL: defaultBaseURL, + APIMode: env.GetOrDefaultString(EnvMode, modeDMAPI), Debug: env.GetOrDefaultBool(EnvDebug, false), TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 60*time.Second), }, } } -// DNSProvider implements the challenge.Provider interface. -type DNSProvider struct { - config *Config -} - -// NewDNSProvider returns a DNSProvider instance configured for Joker DMAPI. +// NewDNSProvider returns a DNSProvider instance configured for Joker. // Credentials must be passed in the environment variable JOKER_API_KEY. -func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvAPIKey) - if err != nil { - var errU error - values, errU = env.Get(EnvUsername, EnvPassword) - if errU != nil { - return nil, fmt.Errorf("joker: %v or %v", errU, err) - } +func NewDNSProvider() (challenge.ProviderTimeout, error) { + if os.Getenv(EnvMode) == modeSVC { + return newSvcProvider() } - config := NewDefaultConfig() - config.APIKey = values[EnvAPIKey] - config.Username = values[EnvUsername] - config.Password = values[EnvPassword] - - return NewDNSProviderConfig(config) + return newDmapiProvider() } -// NewDNSProviderConfig return a DNSProvider instance configured for Joker DMAPI. -func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { - if config == nil { - return nil, errors.New("joker: the configuration of the DNS provider is nil") +// NewDNSProviderConfig return a DNSProvider instance configured for Joker. +func NewDNSProviderConfig(config *Config) (challenge.ProviderTimeout, error) { + if config.APIMode == modeSVC { + return newSvcProviderConfig(config) } - if config.APIKey == "" { - if config.Username == "" || config.Password == "" { - return nil, errors.New("joker: credentials missing") - } - } - - if !strings.HasSuffix(config.BaseURL, "/") { - config.BaseURL += "/" - } - - return &DNSProvider{config: config}, nil -} - -// Timeout returns the timeout and interval to use when checking for DNS propagation. -func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.config.PropagationTimeout, d.config.PollingInterval -} - -// Present installs a TXT record for the DNS challenge. -func (d *DNSProvider) Present(domain, token, keyAuth string) error { - fqdn, value := dns01.GetRecord(domain, keyAuth) - - zone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return fmt.Errorf("joker: %w", err) - } - - relative := getRelative(fqdn, zone) - - if d.config.Debug { - log.Infof("[%s] joker: adding TXT record %q to zone %q with value %q", domain, relative, zone, value) - } - - response, err := d.login() - if err != nil { - return formatResponseError(response, err) - } - - response, err = d.getZone(zone) - if err != nil || response.StatusCode != 0 { - return formatResponseError(response, err) - } - - dnsZone := addTxtEntryToZone(response.Body, relative, value, d.config.TTL) - - response, err = d.putZone(zone, dnsZone) - if err != nil || response.StatusCode != 0 { - return formatResponseError(response, err) - } - - return nil -} - -// CleanUp removes a TXT record used for a previous DNS challenge. -func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { - fqdn, _ := dns01.GetRecord(domain, keyAuth) - - zone, err := dns01.FindZoneByFqdn(fqdn) - if err != nil { - return fmt.Errorf("joker: %w", err) - } - - relative := getRelative(fqdn, zone) - - if d.config.Debug { - log.Infof("[%s] joker: removing entry %q from zone %q", domain, relative, zone) - } - - response, err := d.login() - if err != nil { - return formatResponseError(response, err) - } - - defer func() { - // Try to logout in case of errors - _, _ = d.logout() - }() - - response, err = d.getZone(zone) - if err != nil || response.StatusCode != 0 { - return formatResponseError(response, err) - } - - dnsZone, modified := removeTxtEntryFromZone(response.Body, relative) - if modified { - response, err = d.putZone(zone, dnsZone) - if err != nil || response.StatusCode != 0 { - return formatResponseError(response, err) - } - } - - response, err = d.logout() - if err != nil { - return formatResponseError(response, err) - } - return nil -} - -func getRelative(fqdn, zone string) string { - return dns01.UnFqdn(strings.TrimSuffix(fqdn, dns01.ToFqdn(zone))) -} - -// formatResponseError formats error with optional details from DMAPI response. -func formatResponseError(response *response, err error) error { - if response != nil { - return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers) - } - return fmt.Errorf("joker: DMAPI error: %w", err) + return newDmapiProviderConfig(config) } diff --git a/providers/dns/joker/joker.toml b/providers/dns/joker/joker.toml index 53a2b811..a7137b5d 100644 --- a/providers/dns/joker/joker.toml +++ b/providers/dns/joker/joker.toml @@ -5,19 +5,29 @@ Code = "joker" Since = "v2.6.0" Example = ''' +# SVC +JOKER_API_MODE=SVC \ JOKER_USERNAME= \ JOKER_PASSWORD= \ lego --dns joker --domains my.domain.com --email my@email.com run -# or + +# DMAPI +JOKER_API_MODE=DMAPI \ +JOKER_USERNAME= \ +JOKER_PASSWORD= \ +lego --dns joker --domains my.domain.com --email my@email.com run +## or +JOKER_API_MODE=DMAPI \ JOKER_API_KEY= \ lego --dns joker --domains my.domain.com --email my@email.com run ''' [Configuration] [Configuration.Credentials] - JOKER_API_KEY = "API key" + JOKER_API_MODE = "'DMAPI' or 'SVC'. DMAPI is for resellers accounts. (Default: DMAPI)" JOKER_USERNAME = "Joker.com username (email address)" JOKER_PASSWORD = "Joker.com password" + JOKER_API_KEY = "API key (only with DMAPI mode)" [Configuration.Additional] JOKER_POLLING_INTERVAL = "Time between DNS propagation check" JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" @@ -26,3 +36,4 @@ lego --dns joker --domains my.domain.com --email my@email.com run [Links] API = "https://joker.com/faq/category/39/22-dmapi.html" + API_SVC = "https://joker.com/faq/content/6/496/en/let_s-encrypt-support.html" diff --git a/providers/dns/joker/joker_test.go b/providers/dns/joker/joker_test.go index b647c07b..a71e4d9f 100644 --- a/providers/dns/joker/joker_test.go +++ b/providers/dns/joker/joker_test.go @@ -1,6 +1,8 @@ package joker import ( + "fmt" + "os" "testing" "time" @@ -11,54 +13,40 @@ import ( const envDomain = envNamespace + "DOMAIN" -var envTest = tester.NewEnvTest(EnvAPIKey, EnvUsername, EnvPassword). +var envTest = tester.NewEnvTest(EnvAPIKey, EnvUsername, EnvPassword, EnvMode). WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string - expected string + expected interface{} }{ { - desc: "success API key", - envVars: map[string]string{ - EnvAPIKey: "123", - }, - }, - { - desc: "success username password", + desc: "mode DMAPI (default)", envVars: map[string]string{ EnvUsername: "123", EnvPassword: "123", }, + expected: &dmapiProvider{}, }, { - desc: "missing credentials", + desc: "mode DMAPI", envVars: map[string]string{ - EnvAPIKey: "", - EnvUsername: "", - EnvPassword: "", - }, - expected: "joker: some credentials information are missing: JOKER_USERNAME,JOKER_PASSWORD or some credentials information are missing: JOKER_API_KEY", - }, - { - desc: "missing password", - envVars: map[string]string{ - EnvAPIKey: "", + EnvMode: modeDMAPI, EnvUsername: "123", - EnvPassword: "", - }, - expected: "joker: some credentials information are missing: JOKER_PASSWORD or some credentials information are missing: JOKER_API_KEY", - }, - { - desc: "missing username", - envVars: map[string]string{ - EnvAPIKey: "", - EnvUsername: "", EnvPassword: "123", }, - expected: "joker: some credentials information are missing: JOKER_USERNAME or some credentials information are missing: JOKER_API_KEY", + expected: &dmapiProvider{}, + }, + { + desc: "mode SVC", + envVars: map[string]string{ + EnvMode: modeSVC, + EnvUsername: "123", + EnvPassword: "123", + }, + expected: &svcProvider{}, }, } @@ -69,92 +57,51 @@ func TestNewDNSProvider(t *testing.T) { envTest.Apply(test.envVars) - p, err := NewDNSProvider() + fmt.Println(os.Getenv(EnvMode)) - if len(test.expected) == 0 { - require.NoError(t, err) - require.NotNil(t, p) - assert.NotNil(t, p.config) - } else { - require.EqualError(t, err, test.expected) - } + p, err := NewDNSProvider() + require.NoError(t, err) + require.NotNil(t, p) + + assert.IsType(t, test.expected, p) }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { - desc string - apiKey string - username string - password string - baseURL string - expected string - expectedBaseURL string + desc string + mode string + expected interface{} }{ { - desc: "success api key", - apiKey: "123", - expectedBaseURL: defaultBaseURL, + desc: "mode DMAPI (default)", + expected: &dmapiProvider{}, }, { - desc: "success username and password", - username: "123", - password: "123", - expectedBaseURL: defaultBaseURL, + desc: "mode DMAPI", + mode: modeDMAPI, + expected: &dmapiProvider{}, }, { - desc: "missing credentials", - expected: "joker: credentials missing", - expectedBaseURL: defaultBaseURL, - }, - { - desc: "missing credentials: username", - expected: "joker: credentials missing", - username: "123", - expectedBaseURL: defaultBaseURL, - }, - { - desc: "missing credentials: password", - expected: "joker: credentials missing", - password: "123", - expectedBaseURL: defaultBaseURL, - }, - { - desc: "Base URL should ends with /", - apiKey: "123", - baseURL: "http://example.com", - expectedBaseURL: "http://example.com/", - }, - { - desc: "Base URL already ends with /", - apiKey: "123", - baseURL: "http://example.com/", - expectedBaseURL: "http://example.com/", + desc: "mode SVC", + mode: modeSVC, + expected: &svcProvider{}, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() - config.APIKey = test.apiKey - config.Username = test.username - config.Password = test.password - - if test.baseURL != "" { - config.BaseURL = test.baseURL - } + config.Username = "123" + config.Password = "123" + config.APIMode = test.mode p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, p) - if len(test.expected) == 0 { - require.NoError(t, err) - require.NotNil(t, p) - assert.NotNil(t, p.config) - assert.Equal(t, test.expectedBaseURL, p.config.BaseURL) - } else { - require.EqualError(t, err, test.expected) - } + assert.IsType(t, test.expected, p) }) } } diff --git a/providers/dns/joker/provider_dmapi.go b/providers/dns/joker/provider_dmapi.go new file mode 100644 index 00000000..11934959 --- /dev/null +++ b/providers/dns/joker/provider_dmapi.go @@ -0,0 +1,164 @@ +package joker + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/joker/internal/dmapi" +) + +// dmapiProvider implements the challenge.Provider interface. +type dmapiProvider struct { + config *Config + client *dmapi.Client +} + +// newDmapiProvider returns a DNSProvider instance configured for Joker. +// Credentials must be passed in the environment variable: JOKER_USERNAME, JOKER_PASSWORD or JOKER_API_KEY. +func newDmapiProvider() (*dmapiProvider, error) { + values, err := env.Get(EnvAPIKey) + if err != nil { + var errU error + values, errU = env.Get(EnvUsername, EnvPassword) + if errU != nil { + return nil, fmt.Errorf("joker: %v or %v", errU, err) + } + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return newDmapiProviderConfig(config) +} + +// newDmapiProviderConfig return a DNSProvider instance configured for Joker. +func newDmapiProviderConfig(config *Config) (*dmapiProvider, error) { + if config == nil { + return nil, errors.New("joker: the configuration of the DNS provider is nil") + } + + if config.APIKey == "" { + if config.Username == "" || config.Password == "" { + return nil, errors.New("joker: credentials missing") + } + } + + client := dmapi.NewClient(dmapi.AuthInfo{ + APIKey: config.APIKey, + Username: config.Username, + Password: config.Password, + }) + + client.Debug = config.Debug + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &dmapiProvider{config: config, client: client}, 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 *dmapiProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *dmapiProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + zone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("joker: %w", err) + } + + relative := getRelative(fqdn, zone) + + if d.config.Debug { + log.Infof("[%s] joker: adding TXT record %q to zone %q with value %q", domain, relative, zone, value) + } + + response, err := d.client.Login() + if err != nil { + return formatResponseError(response, err) + } + + response, err = d.client.GetZone(zone) + if err != nil || response.StatusCode != 0 { + return formatResponseError(response, err) + } + + dnsZone := dmapi.AddTxtEntryToZone(response.Body, relative, value, d.config.TTL) + + response, err = d.client.PutZone(zone, dnsZone) + if err != nil || response.StatusCode != 0 { + return formatResponseError(response, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *dmapiProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + zone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("joker: %w", err) + } + + relative := getRelative(fqdn, zone) + + if d.config.Debug { + log.Infof("[%s] joker: removing entry %q from zone %q", domain, relative, zone) + } + + response, err := d.client.Login() + if err != nil { + return formatResponseError(response, err) + } + + defer func() { + // Try to logout in case of errors + _, _ = d.client.Logout() + }() + + response, err = d.client.GetZone(zone) + if err != nil || response.StatusCode != 0 { + return formatResponseError(response, err) + } + + dnsZone, modified := dmapi.RemoveTxtEntryFromZone(response.Body, relative) + if modified { + response, err = d.client.PutZone(zone, dnsZone) + if err != nil || response.StatusCode != 0 { + return formatResponseError(response, err) + } + } + + response, err = d.client.Logout() + if err != nil { + return formatResponseError(response, err) + } + return nil +} + +func getRelative(fqdn, zone string) string { + return dns01.UnFqdn(strings.TrimSuffix(fqdn, dns01.ToFqdn(zone))) +} + +// formatResponseError formats error with optional details from DMAPI response. +func formatResponseError(response *dmapi.Response, err error) error { + if response != nil { + return fmt.Errorf("joker: DMAPI error: %w Response: %v", err, response.Headers) + } + return fmt.Errorf("joker: DMAPI error: %w", err) +} diff --git a/providers/dns/joker/provider_dmapi_test.go b/providers/dns/joker/provider_dmapi_test.go new file mode 100644 index 00000000..4704f2b8 --- /dev/null +++ b/providers/dns/joker/provider_dmapi_test.go @@ -0,0 +1,129 @@ +package joker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newDmapiProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success API key", + envVars: map[string]string{ + EnvAPIKey: "123", + }, + }, + { + desc: "success username password", + envVars: map[string]string{ + EnvUsername: "123", + EnvPassword: "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvAPIKey: "", + EnvUsername: "", + EnvPassword: "", + }, + expected: "joker: some credentials information are missing: JOKER_USERNAME,JOKER_PASSWORD or some credentials information are missing: JOKER_API_KEY", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvAPIKey: "", + EnvUsername: "123", + EnvPassword: "", + }, + expected: "joker: some credentials information are missing: JOKER_PASSWORD or some credentials information are missing: JOKER_API_KEY", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvAPIKey: "", + EnvUsername: "", + EnvPassword: "123", + }, + expected: "joker: some credentials information are missing: JOKER_USERNAME or some credentials information are missing: JOKER_API_KEY", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := newDmapiProvider() + + if test.expected != "" { + require.EqualError(t, err, test.expected) + } else { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + } + }) + } +} + +func Test_newDmapiProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiKey string + username string + password string + expected string + }{ + { + desc: "success api key", + apiKey: "123", + }, + { + desc: "success username and password", + username: "123", + password: "123", + }, + { + desc: "missing credentials", + expected: "joker: credentials missing", + }, + { + desc: "missing credentials: username", + expected: "joker: credentials missing", + username: "123", + }, + { + desc: "missing credentials: password", + expected: "joker: credentials missing", + password: "123", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.Username = test.username + config.Password = test.password + + p, err := newDmapiProviderConfig(config) + + if test.expected != "" { + require.EqualError(t, err, test.expected) + } else { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + } + }) + } +} diff --git a/providers/dns/joker/provider_svc.go b/providers/dns/joker/provider_svc.go new file mode 100644 index 00000000..d1566dc5 --- /dev/null +++ b/providers/dns/joker/provider_svc.go @@ -0,0 +1,87 @@ +package joker + +import ( + "errors" + "fmt" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/joker/internal/svc" +) + +// svcProvider implements the challenge.Provider interface. +type svcProvider struct { + config *Config + client *svc.Client +} + +// newSvcProvider returns a DNSProvider instance configured for Joker. +// Credentials must be passed in the environment variable: JOKER_USERNAME, JOKER_PASSWORD. +func newSvcProvider() (*svcProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword) + if err != nil { + return nil, fmt.Errorf("joker: %v", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + return newSvcProviderConfig(config) +} + +// newSvcProviderConfig return a DNSProvider instance configured for Joker. +func newSvcProviderConfig(config *Config) (*svcProvider, error) { + if config == nil { + return nil, errors.New("joker: the configuration of the DNS provider is nil") + } + + if config.Username == "" || config.Password == "" { + return nil, errors.New("joker: credentials missing") + } + + client := svc.NewClient(config.Username, config.Password) + + return &svcProvider{config: config, client: client}, 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 *svcProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters. +func (d *svcProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + zone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("joker: %w", err) + } + + relative := getRelative(fqdn, zone) + + return d.client.Send(dns01.UnFqdn(zone), relative, value) +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *svcProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + zone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("joker: %w", err) + } + + relative := getRelative(fqdn, zone) + + return d.client.Send(dns01.UnFqdn(zone), relative, "") +} + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *svcProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} diff --git a/providers/dns/joker/provider_svc_test.go b/providers/dns/joker/provider_svc_test.go new file mode 100644 index 00000000..ad6c74c8 --- /dev/null +++ b/providers/dns/joker/provider_svc_test.go @@ -0,0 +1,114 @@ +package joker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newSvcProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success username password", + envVars: map[string]string{ + EnvUsername: "123", + EnvPassword: "123", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "", + }, + expected: "joker: some credentials information are missing: JOKER_USERNAME,JOKER_PASSWORD", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "123", + EnvPassword: "", + }, + expected: "joker: some credentials information are missing: JOKER_PASSWORD", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvUsername: "", + EnvPassword: "123", + }, + expected: "joker: some credentials information are missing: JOKER_USERNAME", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + defer envTest.RestoreEnv() + envTest.ClearEnv() + + envTest.Apply(test.envVars) + + p, err := newSvcProvider() + + if test.expected != "" { + require.EqualError(t, err, test.expected) + } else { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + } + }) + } +} + +func Test_newSvcProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + expected string + }{ + { + desc: "success username and password", + username: "123", + password: "123", + }, + { + desc: "missing credentials", + expected: "joker: credentials missing", + }, + { + desc: "missing credentials: username", + expected: "joker: credentials missing", + username: "123", + }, + { + desc: "missing credentials: password", + expected: "joker: credentials missing", + password: "123", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + + p, err := newSvcProviderConfig(config) + + if test.expected != "" { + require.EqualError(t, err, test.expected) + } else { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + } + }) + } +}