From 4c9d1979fc0c086ceab8c48319e2ba9c4fa03b56 Mon Sep 17 00:00:00 2001 From: "Alexander D. Kanevskiy" Date: Sun, 28 Apr 2019 15:33:50 +0300 Subject: [PATCH] Add support for Joker.com DMAPI (#870) --- README.md | 13 +- cmd/zz_gen_cmd_dnshelp.go | 21 ++ docs/content/dns/zz_gen_joker.md | 62 ++++ providers/dns/dns_providers.go | 3 + providers/dns/joker/client.go | 197 +++++++++++++ providers/dns/joker/client_test.go | 447 +++++++++++++++++++++++++++++ providers/dns/joker/joker.go | 174 +++++++++++ providers/dns/joker/joker.toml | 22 ++ providers/dns/joker/joker_test.go | 135 +++++++++ 9 files changed, 1068 insertions(+), 6 deletions(-) create mode 100644 docs/content/dns/zz_gen_joker.md create mode 100644 providers/dns/joker/client.go create mode 100644 providers/dns/joker/client_test.go create mode 100644 providers/dns/joker/joker.go create mode 100644 providers/dns/joker/joker.toml create mode 100644 providers/dns/joker/joker_test.go diff --git a/README.md b/README.md index e14c4ac1..aee6eb0b 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,10 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS](https://go-acme.github.io/lego/dns/fastdns/) | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | -| [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | -| [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | -| [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | -| [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | -| [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | -| [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | +| [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | +| [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | +| [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | +| [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | +| [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | +| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | +| [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 474cc837..ea9aadc3 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -47,6 +47,7 @@ func allDNSCodes() string { "httpreq", "iij", "inwx", + "joker", "lightsail", "linode", "linodev4", @@ -724,6 +725,26 @@ func displayDNSHelp(name string) { fmt.Fprintln(w) fmt.Fprintln(w, `More information: https://go-acme.github.io/lego/dns/inwx`) + case "joker": + // generated from: providers/dns/joker/joker.toml + fmt.Fprintln(w, `Configuration for Joker.`) + fmt.Fprintln(w, `Code: 'joker'`) + fmt.Fprintln(w, `Since: 'v2.6.0'`) + fmt.Fprintln(w) + + fmt.Fprintln(w, `Credentials:`) + fmt.Fprintln(w, ` - "JOKER_API_KEY": API key`) + fmt.Fprintln(w) + + fmt.Fprintln(w, `Additional Configuration:`) + fmt.Fprintln(w, ` - "JOKER_HTTP_TIMEOUT": API request timeout`) + fmt.Fprintln(w, ` - "JOKER_POLLING_INTERVAL": Time between DNS propagation check`) + fmt.Fprintln(w, ` - "JOKER_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + fmt.Fprintln(w, ` - "JOKER_TTL": The TTL of the TXT record used for the DNS challenge`) + + fmt.Fprintln(w) + fmt.Fprintln(w, `More information: https://go-acme.github.io/lego/dns/joker`) + case "lightsail": // generated from: providers/dns/lightsail/lightsail.toml fmt.Fprintln(w, `Configuration for Amazon Lightsail.`) diff --git a/docs/content/dns/zz_gen_joker.md b/docs/content/dns/zz_gen_joker.md new file mode 100644 index 00000000..f41ffb5a --- /dev/null +++ b/docs/content/dns/zz_gen_joker.md @@ -0,0 +1,62 @@ +--- +title: "Joker" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: joker +--- + + + + + +Since: v2.6.0 + +Configuration for [Joker](https://joker.com). + + + + +- Code: `joker` + +Here is an example bash command using the Joker provider: + +```bash +JOKER_API_KEY= \ +lego --dns joker --domains my.domain.com --email my@email.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `JOKER_API_KEY` | API key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `JOKER_HTTP_TIMEOUT` | API request timeout | +| `JOKER_POLLING_INTERVAL` | Time between DNS propagation check | +| `JOKER_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `JOKER_TTL` | The TTL of the TXT record used for the DNS challenge | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + + + +## More information + +- [API documentation](https://joker.com/faq/category/39/22-dmapi.html) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index a661d17c..e9a4c6d2 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -36,6 +36,7 @@ import ( "github.com/go-acme/lego/providers/dns/httpreq" "github.com/go-acme/lego/providers/dns/iij" "github.com/go-acme/lego/providers/dns/inwx" + "github.com/go-acme/lego/providers/dns/joker" "github.com/go-acme/lego/providers/dns/lightsail" "github.com/go-acme/lego/providers/dns/linode" "github.com/go-acme/lego/providers/dns/linodev4" @@ -127,6 +128,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return iij.NewDNSProvider() case "inwx": return inwx.NewDNSProvider() + case "joker": + return joker.NewDNSProvider() case "lightsail": return lightsail.NewDNSProvider() case "linode": diff --git a/providers/dns/joker/client.go b/providers/dns/joker/client.go new file mode 100644 index 00000000..18265a85 --- /dev/null +++ b/providers/dns/joker/client.go @@ -0,0 +1,197 @@ +package joker + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-acme/lego/challenge/dns01" + "github.com/go-acme/lego/log" +) + +const defaultBaseURL = "https://dmapi.joker.com/request/" + +// Joker DMAPI Response +type response struct { + Headers url.Values + Body string + StatusCode int + StatusText string + AuthSid string +} + +// parseResponse parses HTTP response body +func parseResponse(message string) *response { + r := &response{Headers: url.Values{}, StatusCode: -1} + + parts := strings.SplitN(message, "\n\n", 2) + + for _, line := range strings.Split(parts[0], "\n") { + if strings.TrimSpace(line) == "" { + continue + } + + kv := strings.SplitN(line, ":", 2) + + val := "" + if len(kv) == 2 { + val = strings.TrimSpace(kv[1]) + } + + r.Headers.Add(kv[0], val) + + switch kv[0] { + case "Status-Code": + i, err := strconv.Atoi(val) + if err == nil { + r.StatusCode = i + } + case "Status-Text": + r.StatusText = val + case "Auth-Sid": + r.AuthSid = val + } + } + + if len(parts) > 1 { + r.Body = parts[1] + } + + 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 + } + + response, err := d.postRequest("login", url.Values{"api-key": {d.config.APIKey}}) + if err != nil { + return response, err + } + + if response == nil { + return nil, fmt.Errorf("login returned nil response") + } + + if response.AuthSid == "" { + return response, fmt.Errorf("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, fmt.Errorf("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, fmt.Errorf("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, fmt.Errorf("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) + + if len(fields) < 6 || fields[1] != "TXT" { + return line + } + + if fields[3][0] == '"' && fields[4] == `"` { + fields[3] = strings.TrimSpace(fields[3]) + `"` + fields = append(fields[:4], fields[5:]...) + } + + return strings.Join(fields, " ") +} + +// 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 + var zoneEntries []string + for _, line := range strings.Split(zone, "\n") { + if strings.HasPrefix(line, prefix) { + modified = true + continue + } + zoneEntries = append(zoneEntries, line) + } + + 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 { + var zoneEntries []string + + for _, line := range strings.Split(zone, "\n") { + zoneEntries = append(zoneEntries, fixTxtLines(line)) + } + + newZoneEntry := fmt.Sprintf("%s TXT 0 %q %d", relative, value, ttl) + zoneEntries = append(zoneEntries, newZoneEntry) + + return strings.TrimSpace(strings.Join(zoneEntries, "\n")) +} diff --git a/providers/dns/joker/client_test.go b/providers/dns/joker/client_test.go new file mode 100644 index 00000000..e0ead7b5 --- /dev/null +++ b/providers/dns/joker/client_test.go @@ -0,0 +1,447 @@ +package joker + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + correctAuth = "123" + incorrectAuth = "321" + serverErrorAuth = "500" +) + +func setup() (*http.ServeMux, *httptest.Server) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + return mux, server +} + +func TestDNSProvider_login(t *testing.T) { + testCases := []struct { + desc string + authKey string + expectedError bool + expectedStatusCode int + expectedAuthSid string + }{ + { + desc: "correct key", + authKey: correctAuth, + expectedStatusCode: 0, + expectedAuthSid: correctAuth, + }, + { + desc: "incorrect key", + authKey: incorrectAuth, + expectedStatusCode: 2200, + expectedError: true, + }, + { + desc: "server error", + authKey: serverErrorAuth, + expectedStatusCode: -500, + expectedError: true, + }, + { + desc: "non-ok status code", + authKey: "333", + expectedStatusCode: 2202, + expectedError: true, + }, + } + + mux, server := setup() + defer server.Close() + + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + + switch r.FormValue("api-key") { + case correctAuth: + _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\nAuth-Sid: 123\n\ncom\nnet") + case incorrectAuth: + _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") + case serverErrorAuth: + http.NotFound(w, r) + default: + _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: OK\n\ncom\nnet") + } + }) + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.BaseURL = server.URL + config.APIKey = test.authKey + + p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, p) + + response, err := p.login() + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, test.expectedStatusCode, response.StatusCode) + assert.Equal(t, test.expectedAuthSid, response.AuthSid) + } + }) + } +} + +func TestDNSProvider_logout(t *testing.T) { + testCases := []struct { + desc string + authSid string + expectedError bool + expectedStatusCode int + }{ + { + desc: "correct auth-sid", + authSid: correctAuth, + expectedStatusCode: 0, + }, + { + desc: "incorrect auth-sid", + authSid: incorrectAuth, + expectedStatusCode: 2200, + }, + { + desc: "already logged out", + authSid: "", + expectedError: true, + }, + { + desc: "server error", + authSid: serverErrorAuth, + expectedError: true, + }, + } + + mux, server := setup() + defer server.Close() + + mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + + switch r.FormValue("auth-sid") { + case correctAuth: + _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n") + case incorrectAuth: + _, _ = io.WriteString(w, "Status-Code: 2200\nStatus-Text: Authentication error") + default: + http.NotFound(w, r) + } + }) + + 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 + + p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, p) + + response, err := p.logout() + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, test.expectedStatusCode, response.StatusCode) + } + }) + } +} + +func TestDNSProvider_getZone(t *testing.T) { + var testZone = "@ A 0 192.0.2.2 3600" + + testCases := []struct { + desc string + authSid string + domain string + zone string + expectedError bool + expectedStatusCode int + }{ + { + desc: "correct auth-sid, known domain", + authSid: correctAuth, + domain: "known", + zone: testZone, + expectedStatusCode: 0, + }, + { + desc: "incorrect auth-sid, known domain", + authSid: incorrectAuth, + domain: "known", + expectedStatusCode: 2202, + }, + { + desc: "correct auth-sid, unknown domain", + authSid: correctAuth, + domain: "unknown", + expectedStatusCode: 2202, + }, + { + desc: "server error", + authSid: "500", + expectedError: true, + }, + } + + mux, server := setup() + defer server.Close() + + mux.HandleFunc("/dns-zone-get", func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + + authSid := r.FormValue("auth-sid") + domain := r.FormValue("domain") + + switch { + case authSid == correctAuth && domain == "known": + _, _ = io.WriteString(w, "Status-Code: 0\nStatus-Text: OK\n\n"+testZone) + case authSid == incorrectAuth || (authSid == correctAuth && domain == "unknown"): + _, _ = io.WriteString(w, "Status-Code: 2202\nStatus-Text: Authorization error") + default: + http.NotFound(w, r) + } + }) + + 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 + + p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + require.NotNil(t, p) + + response, err := p.getZone(test.domain) + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, test.expectedStatusCode, response.StatusCode) + assert.Equal(t, test.zone, response.Body) + } + }) + } +} + +func Test_parseResponse(t *testing.T) { + testCases := []struct { + desc string + input string + expected *response + }{ + { + desc: "Empty response", + input: "", + expected: &response{ + Headers: url.Values{}, + StatusCode: -1, + }, + }, + { + desc: "No headers, just body", + input: "\n\nTest body", + expected: &response{ + Headers: url.Values{}, + Body: "Test body", + StatusCode: -1, + }, + }, + { + desc: "Headers and body", + input: "Test-Header: value\n\nTest body", + expected: &response{ + Headers: url.Values{"Test-Header": {"value"}}, + Body: "Test body", + StatusCode: -1, + }, + }, + { + desc: "Headers and body + Auth-Sid", + input: "Test-Header: value\nAuth-Sid: 123\n\nTest body", + expected: &response{ + Headers: url.Values{"Test-Header": {"value"}, "Auth-Sid": {"123"}}, + Body: "Test body", + StatusCode: -1, + AuthSid: "123", + }, + }, + { + desc: "Headers and body + Status-Text", + input: "Test-Header: value\nStatus-Text: OK\n\nTest body", + expected: &response{ + Headers: url.Values{"Test-Header": {"value"}, "Status-Text": {"OK"}}, + Body: "Test body", + StatusText: "OK", + StatusCode: -1, + }, + }, + { + desc: "Headers and body + Status-Code", + input: "Test-Header: value\nStatus-Code: 2020\n\nTest body", + expected: &response{ + Headers: url.Values{"Test-Header": {"value"}, "Status-Code": {"2020"}}, + Body: "Test body", + StatusCode: 2020, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + response := parseResponse(test.input) + + assert.Equal(t, test.expected, response) + }) + } +} + +func Test_removeTxtEntryFromZone(t *testing.T) { + testCases := []struct { + desc string + input string + expected string + modified bool + }{ + { + desc: "empty zone", + input: "", + expected: "", + modified: false, + }, + { + desc: "zone with only A entry", + input: "@ A 0 192.0.2.2 3600", + expected: "@ A 0 192.0.2.2 3600", + modified: false, + }, + { + desc: "zone with only clenup entry", + input: "_acme-challenge TXT 0 \"old \" 120", + expected: "", + modified: true, + }, + { + desc: "zone with one A and one cleanup entries", + input: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"old \" 120", + expected: "@ A 0 192.0.2.2 3600", + modified: true, + }, + { + desc: "zone with one A and multiple cleanup entries", + input: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"old \" 120\n_acme-challenge TXT 0 \"another \" 120", + expected: "@ A 0 192.0.2.2 3600", + modified: true, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + zone, modified := removeTxtEntryFromZone(test.input, "_acme-challenge") + assert.Equal(t, zone, test.expected) + assert.Equal(t, modified, test.modified) + }) + } +} + +func Test_addTxtEntryToZone(t *testing.T) { + testCases := []struct { + desc string + input string + expected string + }{ + { + desc: "empty zone", + input: "", + expected: "_acme-challenge TXT 0 \"test\" 120", + }, + { + desc: "zone with A entry", + input: "@ A 0 192.0.2.2 3600", + expected: "@ A 0 192.0.2.2 3600\n_acme-challenge TXT 0 \"test\" 120", + }, + { + desc: "zone with required cleanup entry", + input: "_acme-challenge TXT 0 \"old \" 120", + expected: "_acme-challenge TXT 0 \"old\" 120\n_acme-challenge TXT 0 \"test\" 120", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + + zone := addTxtEntryToZone(test.input, "_acme-challenge", "test", 120) + assert.Equal(t, zone, test.expected) + }) + } +} + +func Test_fixTxtLines(t *testing.T) { + testCases := []struct { + desc string + input string + expected string + }{ + { + desc: "clean-up", + input: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE " 120`, + expected: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`, + }, + { + desc: "already cleaned", + input: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`, + expected: `_acme-challenge TXT 0 "SrqD25Gpm3WtIGKCqhgsLeXWE_FAD5Hv9CRoLAHxlIE" 120`, + }, + { + desc: "special DNS entry", + input: "$dyndns=yes:username:password", + expected: "$dyndns=yes:username:password", + }, + { + desc: "SRV entry", + input: "_jabber._tcp SRV 20/0 xmpp-server1.l.google.com:5269 300", + expected: "_jabber._tcp SRV 20/0 xmpp-server1.l.google.com:5269 300", + }, + { + desc: "MX entry", + input: "@ MX 10 ASPMX.L.GOOGLE.COM 300", + expected: "@ MX 10 ASPMX.L.GOOGLE.COM 300", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + + line := fixTxtLines(test.input) + assert.Equal(t, line, test.expected) + }) + } +} diff --git a/providers/dns/joker/joker.go b/providers/dns/joker/joker.go new file mode 100644 index 00000000..d2dc1cac --- /dev/null +++ b/providers/dns/joker/joker.go @@ -0,0 +1,174 @@ +// Package joker implements a DNS provider for solving the DNS-01 challenge using joker.com DMAPI. +package joker + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-acme/lego/challenge/dns01" + "github.com/go-acme/lego/log" + "github.com/go-acme/lego/platform/config/env" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + Debug bool + BaseURL string + APIKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client + AuthSid string +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + BaseURL: defaultBaseURL, + Debug: env.GetOrDefaultBool("JOKER_DEBUG", false), + TTL: env.GetOrDefaultInt("JOKER_TTL", dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond("JOKER_PROPAGATION_TIMEOUT", dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("JOKER_POLLING_INTERVAL", dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("JOKER_HTTP_TIMEOUT", 60*time.Second), + }, + } +} + +// DNSProvider is an implementation of the ChallengeProviderTimeout interface +// that uses Joker's DMAPI to manage TXT records for a domain. +type DNSProvider struct { + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for Joker DMAPI. +// Credentials must be passed in the environment variable JOKER_API_KEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("JOKER_API_KEY") + if err != nil { + return nil, fmt.Errorf("joker: %v", err) + } + + config := NewDefaultConfig() + config.APIKey = values["JOKER_API_KEY"] + + return NewDNSProviderConfig(config) +} + +// 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") + } + + if config.APIKey == "" { + return nil, fmt.Errorf("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: %v", 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: %v", 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: %v Response: %v", err, response.Headers) + } + return fmt.Errorf("joker: DMAPI error: %v", err) +} diff --git a/providers/dns/joker/joker.toml b/providers/dns/joker/joker.toml new file mode 100644 index 00000000..0cdace4d --- /dev/null +++ b/providers/dns/joker/joker.toml @@ -0,0 +1,22 @@ +Name = "Joker" +Description = '''''' +URL = "https://joker.com" +Code = "joker" +Since = "v2.6.0" + +Example = ''' +JOKER_API_KEY= \ +lego --dns joker --domains my.domain.com --email my@email.com run +''' + +[Configuration] + [Configuration.Credentials] + JOKER_API_KEY = "API key" + [Configuration.Additional] + JOKER_POLLING_INTERVAL = "Time between DNS propagation check" + JOKER_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + JOKER_TTL = "The TTL of the TXT record used for the DNS challenge" + JOKER_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://joker.com/faq/category/39/22-dmapi.html" diff --git a/providers/dns/joker/joker_test.go b/providers/dns/joker/joker_test.go new file mode 100644 index 00000000..427ac29d --- /dev/null +++ b/providers/dns/joker/joker_test.go @@ -0,0 +1,135 @@ +package joker + +import ( + "testing" + "time" + + "github.com/go-acme/lego/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest("JOKER_API_KEY").WithDomain("JOKER_DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + "JOKER_API_KEY": "123", + }, + }, + { + desc: "missing key", + envVars: map[string]string{ + "JOKER_API_KEY": "", + }, + expected: "joker: 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 := NewDNSProvider() + + 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) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + authKey string + baseURL string + expected string + expectedBaseURL string + }{ + { + desc: "success", + authKey: "123", + expectedBaseURL: defaultBaseURL, + }, + { + desc: "missing credentials", + expected: "joker: credentials missing", + expectedBaseURL: defaultBaseURL, + }, + { + desc: "Base URL should ends with /", + authKey: "123", + baseURL: "http://example.com", + expectedBaseURL: "http://example.com/", + }, + { + desc: "Base URL already ends with /", + authKey: "123", + baseURL: "http://example.com/", + expectedBaseURL: "http://example.com/", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.authKey + if test.baseURL != "" { + config.BaseURL = test.baseURL + } + + p, err := NewDNSProviderConfig(config) + + 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) + } + }) + } +} + +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) +}