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/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 +} 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.