diff --git a/README.md b/README.md index df315119..f84f8b9f 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,10 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | -| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | -| [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | -| [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | +| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | +| [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | +| [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 6a90ef22..eac2afad 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -107,6 +107,7 @@ func allDNSCodes() string { "stackpath", "tencentcloud", "transip", + "variomedia", "vegadns", "vercel", "versio", @@ -2110,6 +2111,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/transip`) + case "variomedia": + // generated from: providers/dns/variomedia/variomedia.toml + ew.writeln(`Configuration for Variomedia.`) + ew.writeln(`Code: 'variomedia'`) + ew.writeln(`Since: 'v4.8.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "VARIOMEDIA_API_TOKEN": API token`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "DODE_SEQUENCE_INTERVAL": Time between sequential requests`) + ew.writeln(` - "VARIOMEDIA_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "VARIOMEDIA_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "VARIOMEDIA_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "VARIOMEDIA_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/variomedia`) + case "vegadns": // generated from: providers/dns/vegadns/vegadns.toml ew.writeln(`Configuration for VegaDNS.`) diff --git a/docs/content/dns/zz_gen_variomedia.md b/docs/content/dns/zz_gen_variomedia.md new file mode 100644 index 00000000..f6481507 --- /dev/null +++ b/docs/content/dns/zz_gen_variomedia.md @@ -0,0 +1,63 @@ +--- +title: "Variomedia" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: variomedia +--- + + + + + +Since: v4.8.0 + +Configuration for [Variomedia](https://www.variomedia.de/). + + + + +- Code: `variomedia` + +Here is an example bash command using the Variomedia provider: + +```bash +VARIOMEDIA_API_TOKEN=xxxx \ +lego --email myemail@example.com --dns variomedia --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `VARIOMEDIA_API_TOKEN` | API token | + +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 | +|--------------------------------|-------------| +| `DODE_SEQUENCE_INTERVAL` | Time between sequential requests | +| `VARIOMEDIA_HTTP_TIMEOUT` | API request timeout | +| `VARIOMEDIA_POLLING_INTERVAL` | Time between DNS propagation check | +| `VARIOMEDIA_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `VARIOMEDIA_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://api.variomedia.de/docs/dns-records.html) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 93822058..ccd7064d 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -98,6 +98,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/stackpath" "github.com/go-acme/lego/v4/providers/dns/tencentcloud" "github.com/go-acme/lego/v4/providers/dns/transip" + "github.com/go-acme/lego/v4/providers/dns/variomedia" "github.com/go-acme/lego/v4/providers/dns/vegadns" "github.com/go-acme/lego/v4/providers/dns/vercel" "github.com/go-acme/lego/v4/providers/dns/versio" @@ -301,6 +302,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return tencentcloud.NewDNSProvider() case "transip": return transip.NewDNSProvider() + case "variomedia": + return variomedia.NewDNSProvider() case "vegadns": return vegadns.NewDNSProvider() case "vercel": diff --git a/providers/dns/variomedia/internal/client.go b/providers/dns/variomedia/internal/client.go new file mode 100644 index 00000000..0e3743ee --- /dev/null +++ b/providers/dns/variomedia/internal/client.go @@ -0,0 +1,137 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "time" +) + +const defaultBaseURL = "https://api.variomedia.de" + +type Client struct { + apiToken string + baseURL *url.URL + HTTPClient *http.Client +} + +func NewClient(apiToken string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + apiToken: apiToken, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c Client) CreateDNSRecord(record DNSRecord) (*CreateDNSRecordResponse, error) { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "dns-records")) + if err != nil { + return nil, err + } + + data := CreateDNSRecordRequest{Data: Data{ + Type: "dns-record", + Attributes: record, + }} + + body, err := json.Marshal(data) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + var result CreateDNSRecordResponse + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c Client) DeleteDNSRecord(id string) (*DeleteRecordResponse, error) { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "dns-records", id)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, endpoint.String(), nil) + if err != nil { + return nil, err + } + + var result DeleteRecordResponse + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c Client) GetJob(id string) (*GetJobResponse, error) { + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "queue-jobs", id)) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, err + } + + var result GetJobResponse + err = c.do(req, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c Client) do(req *http.Request, data interface{}) error { + req.Header.Set("Content-Type", "application/vnd.api+json") + req.Header.Set("Accept", "application/vnd.variomedia.v1+json") + req.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + all, _ := io.ReadAll(resp.Body) + + var e APIError + err = json.Unmarshal(all, &e) + if err != nil { + return fmt.Errorf("%d: %s", resp.StatusCode, string(all)) + } + + return e + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + err = json.Unmarshal(content, data) + if err != nil { + return fmt.Errorf("%w: %s", err, string(content)) + } + + return nil +} diff --git a/providers/dns/variomedia/internal/client_test.go b/providers/dns/variomedia/internal/client_test.go new file mode 100644 index 00000000..a01e3037 --- /dev/null +++ b/providers/dns/variomedia/internal/client_test.go @@ -0,0 +1,185 @@ +package internal + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T) (*Client, *http.ServeMux) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client := NewClient("secret") + client.baseURL, _ = url.Parse(server.URL) + + return client, mux +} + +func mockHandler(method string, filename string) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("invalid method, got %s want %s", req.Method, method), http.StatusBadRequest) + return + } + + filename = "./fixtures/" + filename + statusCode := http.StatusOK + + if req.Header.Get("Authorization") != "token secret" { + statusCode = http.StatusUnauthorized + filename = "./fixtures/error.json" + } + + rw.WriteHeader(statusCode) + + file, err := os.Open(filename) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func TestClient_CreateDNSRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/dns-records", mockHandler(http.MethodPost, "POST_dns-records.json")) + + record := DNSRecord{ + RecordType: "TXT", + Name: "_acme-challenge", + Domain: "example.com", + Data: "test", + TTL: 300, + } + + resp, err := client.CreateDNSRecord(record) + require.NoError(t, err) + + expected := &CreateDNSRecordResponse{ + Data: struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + Status string `json:"status"` + } `json:"attributes"` + Links struct { + QueueJob string `json:"queue-job"` + DNSRecord string `json:"dns-record"` + } `json:"links"` + }{ + Type: "queue-job", + ID: "18181818", + Attributes: struct { + Status string `json:"status"` + }{ + Status: "pending", + }, + Links: struct { + QueueJob string `json:"queue-job"` + DNSRecord string `json:"dns-record"` + }{ + QueueJob: "https://api.variomedia.de/queue-jobs/18181818", + DNSRecord: "https://api.variomedia.de/dns-records/19191919", + }, + }, + } + + assert.Equal(t, expected, resp) +} + +func TestClient_DeleteDNSRecord(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/dns-records/test", mockHandler(http.MethodDelete, "DELETE_dns-records_pending.json")) + + resp, err := client.DeleteDNSRecord("test") + require.NoError(t, err) + + expected := &DeleteRecordResponse{ + Data: struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + JobType string `json:"job_type"` + Status string `json:"status"` + } `json:"attributes"` + Links struct { + Self string `json:"self"` + Object string `json:"object"` + } `json:"links"` + }{ + ID: "303030", + Type: "queue-job", + Attributes: struct { + JobType string `json:"job_type"` + Status string `json:"status"` + }{ + Status: "pending", + }, + }, + } + + assert.Equal(t, expected, resp) +} + +func TestClient_GetJob(t *testing.T) { + client, mux := setup(t) + + mux.HandleFunc("/queue-jobs/test", mockHandler(http.MethodGet, "GET_queue-jobs.json")) + + resp, err := client.GetJob("test") + require.NoError(t, err) + + expected := &GetJobResponse{ + Data: struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + JobType string `json:"job_type"` + Status string `json:"status"` + } `json:"attributes"` + Links struct { + Self string `json:"self"` + Object string `json:"object"` + } `json:"links"` + }{ + ID: "171717", + Type: "queue-job", + Attributes: struct { + JobType string `json:"job_type"` + Status string `json:"status"` + }{ + JobType: "dns-record", + Status: "done", + }, + Links: struct { + Self string `json:"self"` + Object string `json:"object"` + }{ + Self: "https://api.variomedia.de/queue-jobs/171717", + Object: "https://api.variomedia.de/dns-records/212121", + }, + }, + } + + assert.Equal(t, expected, resp) +} diff --git a/providers/dns/variomedia/internal/fixtures/DELETE_dns-records_done.json b/providers/dns/variomedia/internal/fixtures/DELETE_dns-records_done.json new file mode 100644 index 00000000..37d7032a --- /dev/null +++ b/providers/dns/variomedia/internal/fixtures/DELETE_dns-records_done.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": "303030", + "type": "queue-job", + "attributes": { + "job_type": "dns-record", + "status": "done" + }, + "relationships": { + "owner": { + "data": { + "id": "505050", + "type": "customer" + } + } + }, + "links": { + "self": "https://api.variomedia.de/queue-jobs/303030", + "object": "https://api.variomedia.de/dns-records/212121" + } + }, + "links": { + "self": "https://api.variomedia.de/queue-jobs/303030" + } +} diff --git a/providers/dns/variomedia/internal/fixtures/DELETE_dns-records_pending.json b/providers/dns/variomedia/internal/fixtures/DELETE_dns-records_pending.json new file mode 100644 index 00000000..54ca77ea --- /dev/null +++ b/providers/dns/variomedia/internal/fixtures/DELETE_dns-records_pending.json @@ -0,0 +1,15 @@ +{ + "data": { + "id": "303030", + "type": "queue-job", + "attributes": { + "status": "pending" + }, + "links": { + "queue-job": "https://api.variomedia.de/queue-jobs/303030" + } + }, + "links": { + "self": "https://api.variomedia.de/dns-records/212121" + } +} diff --git a/providers/dns/variomedia/internal/fixtures/GET_dns-records.json b/providers/dns/variomedia/internal/fixtures/GET_dns-records.json new file mode 100644 index 00000000..217980fd --- /dev/null +++ b/providers/dns/variomedia/internal/fixtures/GET_dns-records.json @@ -0,0 +1,22 @@ +{ + "data": { + "id": "20202020", + "type": "dns-record", + "links": { + "self": "https://api.variomedia.de/dns-records/20202020" + }, + "attributes": { + "record_type": "TXT", + "fqdn": "my-test-record.example.com", + "fqdn_ace": "my-test-record.example.com", + "name": "my-test-record", + "name_ace": "my-test-record", + "domain": "example.com", + "data": "test", + "ttl": 300 + } + }, + "links": { + "self": "https://api.variomedia.de/dns-records" + } +} diff --git a/providers/dns/variomedia/internal/fixtures/GET_queue-jobs.json b/providers/dns/variomedia/internal/fixtures/GET_queue-jobs.json new file mode 100644 index 00000000..8379266c --- /dev/null +++ b/providers/dns/variomedia/internal/fixtures/GET_queue-jobs.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": "171717", + "type": "queue-job", + "links": { + "self": "https://api.variomedia.de/queue-jobs/171717", + "object": "https://api.variomedia.de/dns-records/212121" + }, + "attributes": { + "job_type": "dns-record", + "status": "done" + }, + "relationships": { + "owner": { + "data": { + "id": "505050", + "type": "customer" + } + } + } + }, + "links": { + "self": "https://api.variomedia.de/queue-jobs/171717" + } +} diff --git a/providers/dns/variomedia/internal/fixtures/POST_dns-records.json b/providers/dns/variomedia/internal/fixtures/POST_dns-records.json new file mode 100644 index 00000000..8d49d1ce --- /dev/null +++ b/providers/dns/variomedia/internal/fixtures/POST_dns-records.json @@ -0,0 +1,16 @@ +{ + "data": { + "type": "queue-job", + "id": "18181818", + "attributes": { + "status": "pending" + }, + "links": { + "queue-job": "https://api.variomedia.de/queue-jobs/18181818", + "dns-record": "https://api.variomedia.de/dns-records/19191919" + } + }, + "links": { + "self": "https://api.variomedia.de/dns-records" + } +} diff --git a/providers/dns/variomedia/internal/fixtures/error.json b/providers/dns/variomedia/internal/fixtures/error.json new file mode 100644 index 00000000..fab65e4e --- /dev/null +++ b/providers/dns/variomedia/internal/fixtures/error.json @@ -0,0 +1,12 @@ +{ + "errors": [ + { + "status": "401", + "title": "The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser doesn't understand how to supply the credentials required.", + "id": "unauthorized" + } + ], + "links": { + "self": "https://api.variomedia.de/dns-records" + } +} diff --git a/providers/dns/variomedia/internal/types.go b/providers/dns/variomedia/internal/types.go new file mode 100644 index 00000000..040bfecb --- /dev/null +++ b/providers/dns/variomedia/internal/types.go @@ -0,0 +1,86 @@ +package internal + +import ( + "fmt" + "strings" +) + +type CreateDNSRecordRequest struct { + Data Data `json:"data"` +} + +type Data struct { + Type string `json:"type"` + Attributes DNSRecord `json:"attributes"` +} + +type DNSRecord struct { + RecordType string `json:"record_type,omitempty"` + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + Data string `json:"data,omitempty"` + TTL int `json:"ttl,omitempty"` +} + +type APIError struct { + Errors []ErrorItem `json:"errors"` +} + +func (a APIError) Error() string { + var parts []string + for _, data := range a.Errors { + parts = append(parts, fmt.Sprintf("status: %s, title: %s, id: %s", data.Status, data.Title, data.ID)) + } + + return strings.Join(parts, ", ") +} + +type ErrorItem struct { + Status string `json:"status,omitempty"` + Title string `json:"title,omitempty"` + ID string `json:"id,omitempty"` +} + +type CreateDNSRecordResponse struct { + Data struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + Status string `json:"status"` + } `json:"attributes"` + Links struct { + QueueJob string `json:"queue-job"` + DNSRecord string `json:"dns-record"` + } `json:"links"` + } `json:"data"` +} + +type GetJobResponse struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + JobType string `json:"job_type"` + Status string `json:"status"` + } `json:"attributes"` + Links struct { + Self string `json:"self"` + Object string `json:"object"` + } `json:"links"` + } `json:"data"` +} + +type DeleteRecordResponse struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + JobType string `json:"job_type"` + Status string `json:"status"` + } `json:"attributes"` + Links struct { + Self string `json:"self"` + Object string `json:"object"` + } `json:"links"` + } `json:"data"` +} diff --git a/providers/dns/variomedia/variomedia.go b/providers/dns/variomedia/variomedia.go new file mode 100644 index 00000000..7c1937af --- /dev/null +++ b/providers/dns/variomedia/variomedia.go @@ -0,0 +1,185 @@ +// Package variomedia implements a DNS provider for solving the DNS-01 challenge using Variomedia DNS. +package variomedia + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/log" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/platform/wait" + "github.com/go-acme/lego/v4/providers/dns/variomedia/internal" +) + +const defaultTTL = 300 + +// Environment variables names. +const ( + envNamespace = "VARIOMEDIA_" + + EnvAPIToken = envNamespace + "API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + APIToken string + + PropagationTimeout time.Duration + PollingInterval time.Duration + SequenceInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client + + recordIDs map[string]string + recordIDsMu sync.Mutex +} + +// NewDNSProvider returns a DNSProvider instance. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIToken) + if err != nil { + return nil, fmt.Errorf("variomedia: %w", err) + } + + config := NewDefaultConfig() + config.APIToken = values[EnvAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Variomedia. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config.APIToken == "" { + return nil, errors.New("variomedia: missing credentials") + } + + if config.HTTPClient == nil { + config.HTTPClient = http.DefaultClient + } + + client := internal.NewClient(config.APIToken) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, 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 +} + +// Sequential All DNS challenges for this provider will be resolved sequentially. +// Returns the interval between each iteration. +func (d *DNSProvider) Sequential() time.Duration { + return d.config.SequenceInterval +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("variomedia: %w", err) + } + + record := internal.DNSRecord{ + RecordType: "TXT", + Name: dns01.UnFqdn(strings.TrimSuffix(fqdn, authZone)), + Domain: dns01.UnFqdn(authZone), + Data: value, + TTL: d.config.TTL, + } + + cdrr, err := d.client.CreateDNSRecord(record) + if err != nil { + return fmt.Errorf("variomedia: %w", err) + } + + err = d.waitJob(domain, cdrr.Data.ID) + if err != nil { + return fmt.Errorf("variomedia: %w", err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = strings.TrimPrefix(cdrr.Data.Links.DNSRecord, "https://api.variomedia.de/dns-records/") + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record previously created. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + // get the record's unique ID from when we created it + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("variomedia: unknown record ID for '%s'", fqdn) + } + + ddrr, err := d.client.DeleteDNSRecord(recordID) + if err != nil { + return fmt.Errorf("variomedia: %w", err) + } + + err = d.waitJob(domain, ddrr.Data.ID) + if err != nil { + return fmt.Errorf("variomedia: %w", err) + } + + return nil +} + +func (d *DNSProvider) waitJob(domain string, id string) error { + return wait.For("variomedia: apply change on "+domain, d.config.PropagationTimeout, d.config.PollingInterval, func() (bool, error) { + result, err := d.client.GetJob(id) + if err != nil { + return false, err + } + + log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status) + + return result.Data.Attributes.Status == "done", nil + }) +} diff --git a/providers/dns/variomedia/variomedia.toml b/providers/dns/variomedia/variomedia.toml new file mode 100644 index 00000000..576dbba7 --- /dev/null +++ b/providers/dns/variomedia/variomedia.toml @@ -0,0 +1,24 @@ +Name = "Variomedia" +Description = '''''' +URL = "https://www.variomedia.de/" +Code = "variomedia" +Since = "v4.8.0" + +Example = ''' +VARIOMEDIA_API_TOKEN=xxxx \ +lego --email myemail@example.com --dns variomedia --domains my.example.org run +''' + +[Configuration] + [Configuration.Credentials] + VARIOMEDIA_API_TOKEN = "API token" + [Configuration.Additional] + VARIOMEDIA_POLLING_INTERVAL = "Time between DNS propagation check" + VARIOMEDIA_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + VARIOMEDIA_TTL = "The TTL of the TXT record used for the DNS challenge" + DODE_SEQUENCE_INTERVAL = "Time between sequential requests" + VARIOMEDIA_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://api.variomedia.de/docs/dns-records.html" + diff --git a/providers/dns/variomedia/variomedia_test.go b/providers/dns/variomedia/variomedia_test.go new file mode 100644 index 00000000..30564607 --- /dev/null +++ b/providers/dns/variomedia/variomedia_test.go @@ -0,0 +1,114 @@ +package variomedia + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvAPIToken).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAPIToken: "secret", + }, + }, + { + desc: "missing API token", + expected: "variomedia: some credentials information are missing: VARIOMEDIA_API_TOKEN", + }, + } + + 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 test.expected == "" { + 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 + expected string + apiToken string + }{ + { + desc: "success", + apiToken: "secret", + }, + { + desc: "missing api token", + apiToken: "", + expected: "variomedia: missing credentials", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +}