04e2d74406
This commit adds a new DNS provider for [acme-dns](https://github.com/joohoi/acme-dns) to allow Lego to set DNS-01 challenge response TXT with an ACME-DNS server automatically. ACME-DNS allows ceding minimal zone editing permissions to the ACME client and can be useful when the primary DNS provider for the zone does not allow scripting/API access but can set a CNAME to an ACME-DNS server. Lower level ACME-DNS API calls & account loading/storing is handled by the `github.com/cpu/goacmedns` library. The provider loads existing ACME-DNS accounts from the specified JSON file on disk. Any accounts the provider registers on behalf of the user will also be saved to this JSON file. When required, the provider handles registering accounts with the ACME-DNS server domains that do not already have an ACME-DNS account. This will halt issuance with an error prompting the user to set the one-time manual CNAME required to delegate the DNS-01 challenge record to the ACME-DNS server. Subsequent runs will use the account from disk and assume the CNAME is in-place.
191 lines
5 KiB
Go
191 lines
5 KiB
Go
package goacmedns
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
"runtime"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// ua is a custom user-agent identifier
|
|
ua = "goacmedns"
|
|
)
|
|
|
|
// userAgent returns a string that can be used as a HTTP request `User-Agent`
|
|
// header. It includes the `ua` string alongside the OS and architecture of the
|
|
// system.
|
|
func userAgent() string {
|
|
return fmt.Sprintf("%s (%s; %s)", ua, runtime.GOOS, runtime.GOARCH)
|
|
}
|
|
|
|
var (
|
|
// defaultTimeout is used for the httpClient Timeout settings
|
|
defaultTimeout = 30 * time.Second
|
|
// httpClient is a `http.Client` that is customized with the `defaultTimeout`
|
|
httpClient = http.Client{
|
|
Transport: &http.Transport{
|
|
Dial: (&net.Dialer{
|
|
Timeout: defaultTimeout,
|
|
KeepAlive: defaultTimeout,
|
|
}).Dial,
|
|
TLSHandshakeTimeout: defaultTimeout,
|
|
ResponseHeaderTimeout: defaultTimeout,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
},
|
|
}
|
|
)
|
|
|
|
// postAPI makes an HTTP POST request to the given URL, sending the given body
|
|
// and attaching the requested custom headers to the request. If there is no
|
|
// error the HTTP response body and HTTP response object are returned, otherwise
|
|
// an error is returned.. All POST requests include a `User-Agent` header
|
|
// populated with the `userAgent` function and a `Content-Type` header of
|
|
// `application/json`.
|
|
func postAPI(url string, body []byte, headers map[string]string) ([]byte, *http.Response, error) {
|
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(body))
|
|
if err != nil {
|
|
fmt.Printf("Failed to make req: %s\n", err.Error())
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", userAgent())
|
|
for h, v := range headers {
|
|
req.Header.Set(h, v)
|
|
}
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
fmt.Printf("Failed to do req: %s\n", err.Error())
|
|
return nil, resp, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
fmt.Printf("Failed to read body: %s\n", err.Error())
|
|
return nil, resp, err
|
|
}
|
|
return respBody, resp, nil
|
|
}
|
|
|
|
// ClientError represents an error from the ACME-DNS server. It holds
|
|
// a `Message` describing the operation the client was doing, a `HTTPStatus`
|
|
// code returned by the server, and the `Body` of the HTTP Response from the
|
|
// server.
|
|
type ClientError struct {
|
|
// Message is a string describing the client operation that failed
|
|
Message string
|
|
// HTTPStatus is the HTTP status code the ACME DNS server returned
|
|
HTTPStatus int
|
|
// Body is the response body the ACME DNS server returned
|
|
Body []byte
|
|
}
|
|
|
|
// Error collects all of the ClientError fields into a single string
|
|
func (e ClientError) Error() string {
|
|
return fmt.Sprintf("%s : status code %d response: %s",
|
|
e.Message, e.HTTPStatus, string(e.Body))
|
|
}
|
|
|
|
// newClientError creates a ClientError instance populated with the given
|
|
// arguments
|
|
func newClientError(msg string, respCode int, respBody []byte) ClientError {
|
|
return ClientError{
|
|
Message: msg,
|
|
HTTPStatus: respCode,
|
|
Body: respBody,
|
|
}
|
|
}
|
|
|
|
// Client is a struct that can be used to interact with an ACME DNS server to
|
|
// register accounts and update TXT records.
|
|
type Client struct {
|
|
// baseURL is the address of the ACME DNS server
|
|
baseURL string
|
|
}
|
|
|
|
// NewClient returns a Client configured to interact with the ACME DNS server at
|
|
// the given URL.
|
|
func NewClient(url string) Client {
|
|
return Client{
|
|
baseURL: url,
|
|
}
|
|
}
|
|
|
|
// RegisterAccount creates an Account with the ACME DNS server. The optional
|
|
// `allowFrom` argument is used to constrain which CIDR ranges can use the
|
|
// created Account.
|
|
func (c Client) RegisterAccount(allowFrom []string) (Account, error) {
|
|
var body []byte
|
|
if len(allowFrom) > 0 {
|
|
req := struct {
|
|
AllowFrom []string
|
|
}{
|
|
AllowFrom: allowFrom,
|
|
}
|
|
reqBody, err := json.Marshal(req)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
body = reqBody
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/register", c.baseURL)
|
|
respBody, resp, err := postAPI(url, body, nil)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return Account{}, newClientError(
|
|
"failed to register account", resp.StatusCode, respBody)
|
|
}
|
|
|
|
var acct Account
|
|
err = json.Unmarshal(respBody, &acct)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
|
|
return acct, nil
|
|
}
|
|
|
|
// UpdateTXTRecord updates a TXT record with the ACME DNS server to the `value`
|
|
// provided using the `account` specified.
|
|
func (c Client) UpdateTXTRecord(account Account, value string) error {
|
|
update := struct {
|
|
SubDomain string
|
|
Txt string
|
|
}{
|
|
SubDomain: account.SubDomain,
|
|
Txt: value,
|
|
}
|
|
updateBody, err := json.Marshal(update)
|
|
if err != nil {
|
|
fmt.Printf("Failed to marshal update: %s\n", update)
|
|
return err
|
|
}
|
|
|
|
headers := map[string]string{
|
|
"X-Api-User": account.Username,
|
|
"X-Api-Key": account.Password,
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/update", c.baseURL)
|
|
respBody, resp, err := postAPI(url, updateBody, headers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return newClientError(
|
|
"failed to update txt record", resp.StatusCode, respBody)
|
|
}
|
|
|
|
return nil
|
|
}
|