Merge pull request #710 from ldez/feature/httpreq

Add DNS provider for "HTTP request".
This commit is contained in:
Ayan George 2018-11-09 06:55:29 -05:00 committed by GitHub
commit fac6e4995c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 519 additions and 0 deletions

2
cli.go
View file

@ -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")

View file

@ -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":

View file

@ -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
}

View file

@ -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
}

View file

@ -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.