Repository: xenolf/lego
Pull request #710 (2018-11-08 18:22:06)

Add DNS provider for "HTTP request".
The server must provide:
- `POST` `/present`
- `POST` `/cleanup`

The URL of the server must be define by `HTTPREQ_ENDPOINT`.

### Mode

There are 2 modes (`HTTPREQ_MODE`):
- default mode:
```json
{
  "fqdn": "_acme-challenge.domain.",
  "value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"
}
```

- `RAW`
```json
{
  "domain": "domain",
  "token": "token",
  "keyAuth": "key"
}
```

### Authentication

Basic authentication (optional) can be set with some environment variables:
- `HTTPREQ_USERNAME` and `HTTPREQ_PASSWORD`
- both values must be set, otherwise basic authentication is not defined.

### Additional information

Thanks to @mdbraber.
Related to #708 




Patch:
diff --git a/cli.go b/cli.go
index c39aa762..319d9a7e 100644
--- a/cli.go
+++ b/cli.go
@@ -226,6 +226,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\tglesys:\tGLESYS_API_USER, GLESYS_API_KEY")
 	fmt.Fprintln(w, "\tgodaddy:\tGODADDY_API_KEY, GODADDY_API_SECRET")
 	fmt.Fprintln(w, "\thostingde:\tHOSTINGDE_API_KEY, HOSTINGDE_ZONE_NAME")
+	fmt.Fprintln(w, "\thttpreq:\tHTTPREQ_ENDPOINT, HTTPREQ_MODE, HTTPREQ_USERNAME, HTTPREQ_PASSWORD")
 	fmt.Fprintln(w, "\tiij:\tIIJ_API_ACCESS_KEY, IIJ_API_SECRET_KEY, IIJ_DO_SERVICE_CODE")
 	fmt.Fprintln(w, "\tinwx:\tINWX_USERNAME, INWX_PASSWORD")
 	fmt.Fprintln(w, "\tlightsail:\tAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, DNS_ZONE")
@@ -275,6 +276,7 @@ Here is an example bash command using the CloudFlare DNS provider:
 	fmt.Fprintln(w, "\tglesys:\tGLESYS_POLLING_INTERVAL, GLESYS_PROPAGATION_TIMEOUT, GLESYS_TTL, GLESYS_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tgodaddy:\tGODADDY_POLLING_INTERVAL, GODADDY_PROPAGATION_TIMEOUT, GODADDY_TTL, GODADDY_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\thostingde:\tHOSTINGDE_POLLING_INTERVAL, HOSTINGDE_PROPAGATION_TIMEOUT, HOSTINGDE_TTL, HOSTINGDE_HTTP_TIMEOUT")
+	fmt.Fprintln(w, "\thttpreq:\t,HTTPREQ_POLLING_INTERVAL, HTTPREQ_PROPAGATION_TIMEOUT, HTTPREQ_HTTP_TIMEOUT")
 	fmt.Fprintln(w, "\tiij:\tIIJ_POLLING_INTERVAL, IIJ_PROPAGATION_TIMEOUT, IIJ_TTL")
 	fmt.Fprintln(w, "\tinwx:\tINWX_POLLING_INTERVAL, INWX_PROPAGATION_TIMEOUT, INWX_TTL, INWX_SANDBOX")
 	fmt.Fprintln(w, "\tlightsail:\tLIGHTSAIL_POLLING_INTERVAL, LIGHTSAIL_PROPAGATION_TIMEOUT")
diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go
index 7002effb..604137d5 100644
--- a/providers/dns/dns_providers.go
+++ b/providers/dns/dns_providers.go
@@ -28,6 +28,7 @@ import (
 	"github.com/xenolf/lego/providers/dns/glesys"
 	"github.com/xenolf/lego/providers/dns/godaddy"
 	"github.com/xenolf/lego/providers/dns/hostingde"
+	"github.com/xenolf/lego/providers/dns/httpreq"
 	"github.com/xenolf/lego/providers/dns/iij"
 	"github.com/xenolf/lego/providers/dns/inwx"
 	"github.com/xenolf/lego/providers/dns/lightsail"
@@ -105,6 +106,8 @@ func NewDNSChallengeProviderByName(name string) (acme.ChallengeProvider, error)
 		return godaddy.NewDNSProvider()
 	case "hostingde":
 		return hostingde.NewDNSProvider()
+	case "httpreq":
+		return httpreq.NewDNSProvider()
 	case "iij":
 		return iij.NewDNSProvider()
 	case "inwx":
diff --git a/providers/dns/httpreq/httpreq.go b/providers/dns/httpreq/httpreq.go
new file mode 100644
index 00000000..cf957d41
--- /dev/null
+++ b/providers/dns/httpreq/httpreq.go
@@ -0,0 +1,193 @@
+// Package httpreq implements a DNS provider for solving the DNS-01 challenge through a HTTP server.
+package httpreq
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"os"
+	"time"
+
+	"github.com/xenolf/lego/acme"
+	"github.com/xenolf/lego/platform/config/env"
+)
+
+type message struct {
+	FQDN  string `json:"fqdn"`
+	Value string `json:"value"`
+}
+
+type messageRaw struct {
+	Domain  string `json:"domain"`
+	Token   string `json:"token"`
+	KeyAuth string `json:"keyAuth"`
+}
+
+// Config is used to configure the creation of the DNSProvider
+type Config struct {
+	Endpoint           *url.URL
+	Mode               string
+	Username           string
+	Password           string
+	PropagationTimeout time.Duration
+	PollingInterval    time.Duration
+	HTTPClient         *http.Client
+}
+
+// NewDefaultConfig returns a default configuration for the DNSProvider
+func NewDefaultConfig() *Config {
+	return &Config{
+		PropagationTimeout: env.GetOrDefaultSecond("HTTPREQ_PROPAGATION_TIMEOUT", acme.DefaultPropagationTimeout),
+		PollingInterval:    env.GetOrDefaultSecond("HTTPREQ_POLLING_INTERVAL", acme.DefaultPollingInterval),
+		HTTPClient: &http.Client{
+			Timeout: env.GetOrDefaultSecond("HTTPREQ_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("HTTPREQ_ENDPOINT")
+	if err != nil {
+		return nil, fmt.Errorf("httpreq: %v", err)
+	}
+
+	endpoint, err := url.Parse(values["HTTPREQ_ENDPOINT"])
+	if err != nil {
+		return nil, fmt.Errorf("httpreq: %v", err)
+	}
+
+	config := NewDefaultConfig()
+	config.Mode = os.Getenv("HTTPREQ_MODE")
+	config.Username = os.Getenv("HTTPREQ_USERNAME")
+	config.Password = os.Getenv("HTTPREQ_PASSWORD")
+	config.Endpoint = endpoint
+	return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider .
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+	if config == nil {
+		return nil, errors.New("httpreq: the configuration of the DNS provider is nil")
+	}
+
+	if config.Endpoint == nil {
+		return nil, errors.New("httpreq: 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 {
+	if d.config.Mode == "RAW" {
+		msg := &messageRaw{
+			Domain:  domain,
+			Token:   token,
+			KeyAuth: keyAuth,
+		}
+
+		err := d.doPost("/present", msg)
+		if err != nil {
+			return fmt.Errorf("httpreq: %v", err)
+		}
+		return nil
+	}
+
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+	msg := &message{
+		FQDN:  fqdn,
+		Value: value,
+	}
+
+	err := d.doPost("/present", msg)
+	if err != nil {
+		return fmt.Errorf("httpreq: %v", err)
+	}
+	return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+	if d.config.Mode == "RAW" {
+		msg := &messageRaw{
+			Domain:  domain,
+			Token:   token,
+			KeyAuth: keyAuth,
+		}
+
+		err := d.doPost("/cleanup", msg)
+		if err != nil {
+			return fmt.Errorf("httpreq: %v", err)
+		}
+		return nil
+	}
+
+	fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
+	msg := &message{
+		FQDN:  fqdn,
+		Value: value,
+	}
+
+	err := d.doPost("/cleanup", msg)
+	if err != nil {
+		return fmt.Errorf("httpreq: %v", err)
+	}
+	return nil
+}
+
+func (d *DNSProvider) doPost(uri string, msg interface{}) error {
+	reqBody := &bytes.Buffer{}
+	err := json.NewEncoder(reqBody).Encode(msg)
+	if err != nil {
+		return err
+	}
+
+	endpoint, err := d.config.Endpoint.Parse(uri)
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(http.MethodPost, endpoint.String(), reqBody)
+	if err != nil {
+		return err
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+
+	if len(d.config.Username) > 0 && len(d.config.Password) > 0 {
+		req.SetBasicAuth(d.config.Username, d.config.Password)
+	}
+
+	resp, err := d.config.HTTPClient.Do(req)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode >= http.StatusBadRequest {
+		body, err := ioutil.ReadAll(resp.Body)
+		if err != nil {
+			return fmt.Errorf("%d: failed to read response body: %v", resp.StatusCode, err)
+		}
+
+		return fmt.Errorf("%d: request failed: %v", resp.StatusCode, string(body))
+	}
+
+	return nil
+}
diff --git a/providers/dns/httpreq/readme.md b/providers/dns/httpreq/readme.md
new file mode 100644
index 00000000..51bc9a0b
--- /dev/null
+++ b/providers/dns/httpreq/readme.md
@@ -0,0 +1,33 @@
+# HTTP request
+
+The server must provide:
+- `POST` `/present`
+- `POST` `/cleanup`
+
+The URL of the server must be define by `HTTPREQ_ENDPOINT`.
+
+## Mode
+
+There are 2 modes (`HTTPREQ_MODE`):
+- default mode:
+```json
+{
+  "fqdn": "_acme-challenge.domain.",
+  "value": "LHDhK3oGRvkiefQnx7OOczTY5Tic_xZ6HcMOc_gmtoM"
+}
+```
+
+- `RAW`
+```json
+{
+  "domain": "domain",
+  "token": "token",
+  "keyAuth": "key"
+}
+```
+
+## Authentication
+
+Basic authentication (optional) can be set with some environment variables:
+- `HTTPREQ_USERNAME` and `HTTPREQ_PASSWORD`
+- both values must be set, otherwise basic authentication is not defined.


Test patch:
diff --git a/providers/dns/httpreq/httpreq_test.go b/providers/dns/httpreq/httpreq_test.go
new file mode 100644
index 00000000..7b64d297
--- /dev/null
+++ b/providers/dns/httpreq/httpreq_test.go
@@ -0,0 +1,288 @@
+package httpreq
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/xenolf/lego/platform/tester"
+)
+
+var envTest = tester.NewEnvTest("HTTPREQ_ENDPOINT", "HTTPREQ_MODE", "HTTPREQ_USERNAME", "HTTPREQ_PASSWORD")
+
+func TestNewDNSProvider(t *testing.T) {
+	testCases := []struct {
+		desc     string
+		envVars  map[string]string
+		expected string
+	}{
+		{
+			desc: "success",
+			envVars: map[string]string{
+				"HTTPREQ_ENDPOINT": "http://localhost:8090",
+			},
+		},
+		{
+			desc: "invalid URL",
+			envVars: map[string]string{
+				"HTTPREQ_ENDPOINT": ":",
+			},
+			expected: "httpreq: parse :: missing protocol scheme",
+		},
+		{
+			desc: "missing endpoint",
+			envVars: map[string]string{
+				"HTTPREQ_ENDPOINT": "",
+			},
+			expected: "httpreq: some credentials information are missing: HTTPREQ_ENDPOINT",
+		},
+	}
+
+	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
+		endpoint *url.URL
+		expected string
+	}{
+		{
+			desc:     "success",
+			endpoint: mustParse("http://localhost:8090"),
+		},
+		{
+			desc:     "missing endpoint",
+			expected: "httpreq: the endpoint is missing",
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.desc, func(t *testing.T) {
+			config := NewDefaultConfig()
+			config.Endpoint = 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 TestNewDNSProvider_Present(t *testing.T) {
+	envTest.RestoreEnv()
+
+	testCases := []struct {
+		desc          string
+		mode          string
+		username      string
+		password      string
+		handler       http.HandlerFunc
+		expectedError string
+	}{
+		{
+			desc:    "success",
+			handler: successHandler,
+		},
+		{
+			desc:          "error",
+			handler:       http.NotFound,
+			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+		},
+		{
+			desc:    "success raw mode",
+			mode:    "RAW",
+			handler: successRawModeHandler,
+		},
+		{
+			desc:          "error raw mode",
+			mode:          "RAW",
+			handler:       http.NotFound,
+			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+		},
+		{
+			desc:     "basic auth",
+			username: "bar",
+			password: "foo",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				username, password, ok := req.BasicAuth()
+				if username != "bar" || password != "foo" || !ok {
+					rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "Please enter your username and password."))
+					http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+					return
+				}
+
+				fmt.Fprint(rw, "lego")
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			mux := http.NewServeMux()
+			mux.HandleFunc("/present", test.handler)
+			server := httptest.NewServer(mux)
+
+			config := NewDefaultConfig()
+			config.Endpoint = mustParse(server.URL)
+			config.Mode = test.mode
+			config.Username = test.username
+			config.Password = test.password
+
+			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 TestNewDNSProvider_Cleanup(t *testing.T) {
+	envTest.RestoreEnv()
+
+	testCases := []struct {
+		desc          string
+		mode          string
+		username      string
+		password      string
+		handler       http.HandlerFunc
+		expectedError string
+	}{
+		{
+			desc:    "success",
+			handler: successHandler,
+		},
+		{
+			desc:          "error",
+			handler:       http.NotFound,
+			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+		},
+		{
+			desc:    "success raw mode",
+			mode:    "RAW",
+			handler: successRawModeHandler,
+		},
+		{
+			desc:          "error raw mode",
+			mode:          "RAW",
+			handler:       http.NotFound,
+			expectedError: "httpreq: 404: request failed: 404 page not found\n",
+		},
+		{
+			desc:     "basic auth",
+			username: "bar",
+			password: "foo",
+			handler: func(rw http.ResponseWriter, req *http.Request) {
+				username, password, ok := req.BasicAuth()
+				if username != "bar" || password != "foo" || !ok {
+					rw.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "Please enter your username and password."))
+					http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+					return
+				}
+				fmt.Fprint(rw, "lego")
+			},
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			mux := http.NewServeMux()
+			mux.HandleFunc("/cleanup", test.handler)
+			server := httptest.NewServer(mux)
+
+			config := NewDefaultConfig()
+			config.Endpoint = mustParse(server.URL)
+			config.Mode = test.mode
+			config.Username = test.username
+			config.Password = test.password
+
+			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 successHandler(rw http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+		return
+	}
+
+	msg := &message{}
+	err := json.NewDecoder(req.Body).Decode(msg)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	fmt.Fprint(rw, "lego")
+}
+
+func successRawModeHandler(rw http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodPost {
+		http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+		return
+	}
+
+	msg := &messageRaw{}
+	err := json.NewDecoder(req.Body).Decode(msg)
+	if err != nil {
+		http.Error(rw, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	fmt.Fprint(rw, "lego")
+}
+
+func mustParse(rawURL string) *url.URL {
+	uri, err := url.Parse(rawURL)
+	if err != nil {
+		panic(err)
+	}
+	return uri
+}