Automatic Certificates and HTTPS for everyone.
| Provider name | CLI flag name | Required lego version | |
|---|---|---|---|
| {{ .Title }} | {{- if $params.url -}} Website {{- end -}} |
{{ $params.code }}
|
{{ $params.since }} |
| {{if .Code }}{{ .Name }}{{end}} | {{- end }}
Error Occurred While Processing Request :
- badysys : Der System Parameter ist ungültig.================================================ FILE: providers/dns/ddnss/internal/fixtures/success.html ================================================
- badauth : Die Authorisation ist fehlgeschlagen. Die Parameter username und/oder password sind falsch.
- notfqdn : Hostname fehlt oder ist falsch.
Updated 1 hostname.
================================================ FILE: providers/dns/ddnss/internal/types.go ================================================ package internal import ( "errors" "net/url" ) type Authentication struct { Username string `url:"user,omitempty"` Password string `url:"pwd,omitempty"` Key string `url:"key,omitempty"` } func (a *Authentication) validate() error { if a.Username == "" && a.Password == "" && a.Key == "" { return errors.New("missing credentials") } if a.Username != "" && a.Password != "" && a.Key != "" { return errors.New("only one of username, password or key can be set") } if (a.Username != "" && a.Password == "") || a.Username == "" && a.Password != "" { return errors.New("username and password must be set together") } return nil } func (a *Authentication) set(query url.Values) { if a.Key != "" { query.Set("key", a.Key) return } query.Set("user", a.Username) query.Set("pwd", a.Password) } ================================================ FILE: providers/dns/derak/derak.go ================================================ // Package derak implements a DNS provider for solving the DNS-01 challenge using Derak Cloud. package derak import ( "context" "errors" "fmt" "net/http" "strings" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/derak/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/miekg/dns" ) // Environment variables names. const ( envNamespace = "DERAK_" EnvAPIKey = envNamespace + "API_KEY" EnvWebsiteID = envNamespace + "WEBSITE_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string WebsiteID string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), 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 configured for Derak Cloud. // Credentials must be passed in the environment variable: DERAK_API_KEY. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("derak: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] config.WebsiteID = env.GetOrDefaultString(EnvWebsiteID, "") return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Derak Cloud. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("derak: the configuration of the DNS provider is nil") } if config.APIKey == "" { return nil, errors.New("derak: missing credentials") } client := internal.NewClient(config.APIKey) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.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 } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("derak: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("derak: %w", err) } zoneID, err := d.getZoneID(ctx, info) if err != nil { return fmt.Errorf("derak: get zone ID: %w", err) } r := internal.Record{ Type: "TXT", Host: recordName, Content: info.Value, TTL: d.config.TTL, } record, err := d.client.CreateRecord(ctx, zoneID, r) if err != nil { return fmt.Errorf("derak: create record: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = record.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) zoneID, err := d.getZoneID(ctx, info) if err != nil { return fmt.Errorf("derak: get zone ID: %w", err) } // gets the record's unique ID d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { return fmt.Errorf("derak: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token) } err = d.client.DeleteRecord(ctx, zoneID, recordID) if err != nil { return fmt.Errorf("derak: delete record: %w", err) } // deletes record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } func (d *DNSProvider) getZoneID(ctx context.Context, info dns01.ChallengeInfo) (string, error) { zoneID := d.config.WebsiteID if zoneID != "" { return zoneID, nil } zones, err := d.client.GetZones(ctx) if err != nil { return "", fmt.Errorf("get zones: %w", err) } for _, zone := range zones { if strings.HasSuffix(info.EffectiveFQDN, dns.Fqdn(zone.HumanReadable)) { return zone.ID, nil } } return "", fmt.Errorf("zone/website not found %s", info.EffectiveFQDN) } ================================================ FILE: providers/dns/derak/derak.toml ================================================ Name = "Derak Cloud" Description = '''''' URL = "https://derak.cloud/" Code = "derak" Since = "v4.12.0" Example = ''' DERAK_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns derak -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DERAK_API_KEY = "The API key" [Configuration.Additional] DERAK_WEBSITE_ID = "Force the zone/website ID" DERAK_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" DERAK_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" DERAK_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DERAK_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" ================================================ FILE: providers/dns/derak/derak_test.go ================================================ package derak import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey, EnvWebsiteID).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing API key", envVars: map[string]string{}, expected: "derak: some credentials information are missing: DERAK_API_KEY", }, } 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) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing API key", expected: "derak: missing credentials", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } 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) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/derak/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) const defaultBaseURL = "https://api.derak.cloud/v1.0" type Client struct { baseURL *url.URL HTTPClient *http.Client zoneEndpoint string apiKey string } func NewClient(apiKey string) *Client { baseURL, _ := url.Parse(defaultBaseURL) return &Client{ HTTPClient: &http.Client{Timeout: 10 * time.Second}, baseURL: baseURL, zoneEndpoint: "https://api.derak.cloud/api/v2/service/cdn/zones", apiKey: apiKey, } } // GetRecords gets all records. // Note: the response is not influenced by the query parameters, so the documentation seems wrong. func (c *Client) GetRecords(ctx context.Context, zoneID string, params *GetRecordsParameters) (*GetRecordsResponse, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords") v, err := querystring.Values(params) if err != nil { return nil, err } endpoint.RawQuery = v.Encode() req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } response := &GetRecordsResponse{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // GetRecord gets a record by ID. func (c *Client) GetRecord(ctx context.Context, zoneID, recordID string) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } response := &Record{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // CreateRecord creates a new record. func (c *Client) CreateRecord(ctx context.Context, zoneID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords") req, err := newJSONRequest(ctx, http.MethodPut, endpoint, record) if err != nil { return nil, err } response := &Record{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // EditRecord edits an existing record. func (c *Client) EditRecord(ctx context.Context, zoneID, recordID string, record Record) (*Record, error) { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodPatch, endpoint, record) if err != nil { return nil, err } response := &Record{} err = c.do(req, response) if err != nil { return nil, err } return response, nil } // DeleteRecord deletes an existing record. func (c *Client) DeleteRecord(ctx context.Context, zoneID, recordID string) error { endpoint := c.baseURL.JoinPath("zones", zoneID, "dnsrecords", recordID) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } response := &APIResponse[any]{} err = c.do(req, response) if err != nil { return err } if !response.Success { return fmt.Errorf("API error: %d %s", response.Error, codeText(response.Error)) } return nil } // GetZones gets zones. // Note: it's not a part of the official API, there is no documentation about this. // The endpoint comes from UI calls analysis. func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.zoneEndpoint, http.NoBody) if err != nil { return nil, err } response := &APIResponse[[]Zone]{} err = c.do(req, response) if err != nil { return nil, err } if !response.Success { return nil, fmt.Errorf("API error: %d %s", response.Error, codeText(response.Error)) } return response.Result, nil } func (c *Client) do(req *http.Request, result any) error { req.SetBasicAuth("api", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() switch req.Method { case http.MethodPut: if resp.StatusCode != http.StatusCreated { return parseError(req, resp) } default: if resp.StatusCode != http.StatusOK { return parseError(req, resp) } } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var response APIResponse[any] err := json.Unmarshal(raw, &response) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %d: %s", resp.StatusCode, response.Error, codeText(response.Error)) } ================================================ FILE: providers/dns/derak/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupClient(server *httptest.Server) (*Client, error) { client := NewClient("secret") client.baseURL, _ = url.Parse(server.URL) client.zoneEndpoint = server.URL client.HTTPClient = server.Client() return client, nil } func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader().WithJSONHeaders(). WithBasicAuth("api", "secret")) } func TestGetRecords(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("records-GET.json")). Build(t) records, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.NoError(t, err) excepted := &GetRecordsResponse{Data: []Record{ { Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", }, { Type: "A", Host: "example.com", Content: "188.114.96.3", ID: "90e6029da45d4a36bf31056cf85d0cab", }, { Type: "AAAA", Host: "example.com", Content: "2a06:98c1:3121::7", ID: "0ac0320da0d24b5ca4f1648986a17340", }, { Type: "AAAA", Host: "example.com", Content: "2a06:98c1:3120::7", ID: "c91599694aea413498a0b3cd0a54a585", }, { Type: "A", Host: "www", Content: "188.114.96.7", ID: "c21f974992d549499f92e768bc468374", }, { Type: "A", Host: "www", Content: "188.114.97.7", ID: "90c3c1f05dca426893f10f122d18ad7a", }, { Type: "AAAA", Host: "www", Content: "2a06:98c1:3121::", ID: "379ab0ac0e434bc9aee5287e497f88a5", }, { Type: "AAAA", Host: "www", Content: "2a06:98c1:3120::", ID: "a1c4f9e50ba74791a4d70dc96999474c", }, }, Count: 8} assert.Equal(t, excepted, records) } func TestGetRecords_error(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetRecords(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", &GetRecordsParameters{DNSType: "TXT", Content: `"test"'`}) require.Error(t, err) } func TestGetRecord(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/812bee17a0b440b0bd5ee099a78b839c", servermock.ResponseFromFixture("record-GET.json")). Build(t) record, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.NoError(t, err) excepted := &Record{ Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", } assert.Equal(t, excepted, record) } func TestGetRecord_error(t *testing.T) { client := mockBuilder(). Route("GET /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "812bee17a0b440b0bd5ee099a78b839c") require.Error(t, err) } func TestCreateRecord(t *testing.T) { client := mockBuilder(). Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("record-PUT.json"). WithStatusCode(http.StatusCreated)). Build(t) r := Record{ Type: "TXT", Host: "test", Content: "test", TTL: 120, } record, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r) require.NoError(t, err) excepted := &Record{ Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", } assert.Equal(t, excepted, record) } func TestCreateRecord_error(t *testing.T) { client := mockBuilder(). Route("PUT /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) r := Record{ Type: "TXT", Host: "test", Content: "test", TTL: 120, } _, err := client.CreateRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", r) require.Error(t, err) } func TestEditRecord(t *testing.T) { client := mockBuilder(). Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", servermock.ResponseFromFixture("record-PATCH.json")). Build(t) record, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", }) require.NoError(t, err) excepted := &Record{ Type: "A", Host: "example.com", Content: "188.114.97.3", ID: "812bee17a0b440b0bd5ee099a78b839c", } assert.Equal(t, excepted, record) } func TestEditRecord_error(t *testing.T) { client := mockBuilder(). Route("PATCH /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/eebc813de2f94d67b09d91e10e2d65c2", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.EditRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "eebc813de2f94d67b09d91e10e2d65c2", Record{ Content: "foo", }) require.Error(t, err) } func TestDeleteRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", servermock.ResponseFromFixture("record-DELETE.json")). Build(t) err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.NoError(t, err) } func TestDeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("DELETE /zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/653464211b7447a1bee6b8fcb9fb86df", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) err := client.DeleteRecord(t.Context(), "47c0ecf6c91243308c649ad1d2d618dd", "653464211b7447a1bee6b8fcb9fb86df") require.Error(t, err) } func TestGetZones(t *testing.T) { client := servermock.NewBuilder[*Client](setupClient, servermock.CheckHeader(). WithBasicAuth("api", "secret"), ). Route("GET /", servermock.ResponseFromFixture("service-cdn-zones.json")). Build(t) zones, err := client.GetZones(t.Context()) require.NoError(t, err) excepted := []Zone{{ ID: "47c0ecf6c91243308c649ad1d2d618dd", Tags: []string{}, ContextID: "47c0ecf6c91243308c649ad1d2d618dd", ContextType: "CDN", HumanReadable: "example.com", Serial: "2301449956", CreationTime: 1679090659902, CreationTimeDate: time.Date(2023, time.March, 17, 22, 4, 19, 902000000, time.UTC), Status: "active", IsMoved: true, Paused: false, ServiceType: "CDN", Limbo: false, TeamName: "test", TeamID: "640ef58496738d38fa7246a4", MyTeam: true, RoleName: "owner", IsBoard: true, BoardRole: []string{"owner"}, }} assert.Equal(t, excepted, zones) } func TestGetZones_error(t *testing.T) { client := mockBuilder(). Route("GET /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusUnauthorized)). Build(t) _, err := client.GetZones(t.Context()) require.Error(t, err) } ================================================ FILE: providers/dns/derak/internal/fixtures/error.json ================================================ {"success":false,"error":1010} ================================================ FILE: providers/dns/derak/internal/fixtures/record-DELETE.json ================================================ { "success": true } ================================================ FILE: providers/dns/derak/internal/fixtures/record-GET.json ================================================ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ================================================ FILE: providers/dns/derak/internal/fixtures/record-PATCH.json ================================================ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ================================================ FILE: providers/dns/derak/internal/fixtures/record-PUT.json ================================================ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ================================================ FILE: providers/dns/derak/internal/fixtures/records-GET.json ================================================ { "data": [ { "recordId": "812bee17a0b440b0bd5ee099a78b839c", "type": "A", "host": "example.com", "content": "188.114.97.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "90e6029da45d4a36bf31056cf85d0cab", "type": "A", "host": "example.com", "content": "188.114.96.3", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "0ac0320da0d24b5ca4f1648986a17340", "type": "AAAA", "host": "example.com", "content": "2a06:98c1:3121::7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "c91599694aea413498a0b3cd0a54a585", "type": "AAAA", "host": "example.com", "content": "2a06:98c1:3120::7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "c21f974992d549499f92e768bc468374", "type": "A", "host": "www", "content": "188.114.96.7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "90c3c1f05dca426893f10f122d18ad7a", "type": "A", "host": "www", "content": "188.114.97.7", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "379ab0ac0e434bc9aee5287e497f88a5", "type": "AAAA", "host": "www", "content": "2a06:98c1:3121::", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" }, { "recordId": "a1c4f9e50ba74791a4d70dc96999474c", "type": "AAAA", "host": "www", "content": "2a06:98c1:3120::", "ttl": 0, "cloud": false, "advanced": false, "customSSLType": "" } ], "count": 8 } ================================================ FILE: providers/dns/derak/internal/fixtures/service-cdn-zones.json ================================================ { "success": true, "result": [ { "zoneId": "47c0ecf6c91243308c649ad1d2d618dd", "tags": [], "contextId": "47c0ecf6c91243308c649ad1d2d618dd", "contextType": "CDN", "humanReadable": "example.com", "serial": "2301449956", "creationTime": 1679090659902, "creationTimeDate": "2023-03-17T22:04:19.902Z", "status": "active", "is_moved": true, "paused": false, "cache": { "developmentMode": false }, "securityOptions": { "level": "off" }, "ssl": { "active": true }, "dns": { "length": 8 }, "serviceType": "CDN", "limbo": false, "teamName": "test", "teamId": "640ef58496738d38fa7246a4", "myTeam": true, "roleName": "owner", "isBoard": true, "boardRole": [ "owner" ] } ] } ================================================ FILE: providers/dns/derak/internal/readme.md ================================================ # Notes ## Forum - https://derak.cloud/faq/programming/%da%86%da%af%d9%88%d9%86%d9%87-%d9%85%db%8c%d8%aa%d9%88%d8%a7%d9%86-%d8%a8%d9%87-api%d9%87%d8%a7-%d8%af%d8%b3%d8%aa%d8%b1%d8%b3%db%8c-%d8%af%d8%a7%d8%b4%d8%aa%d8%9f/ - https://derak.cloud/faq/programming/%d8%af%d8%b1%db%8c%d8%a7%d9%81%d8%aa-%da%a9%d9%84%db%8c%d8%af-api-api-key/ --- ## DNS records (API) ### GET: Get a list of all DNS records ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords` #### Query | The name of the parameter | Description | |---------------------------|----------------------------------| | dnsType | dnsType query | | content | The Host value of the DNS record | #### Errors | type error | Error code | |-------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | #### Example ```bash curl -X GET --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords ``` ```bash curl -X GET --user "api:api-MbmnxdpIBvk14nk5LFFdG1CV9PdMDfqi3tZAixBZLXYzM3qc187d7ede2de" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords \ -F dnsType="TXT" ``` ### PUT: Creating a new DNS record on the desired website ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords` #### parameters | The name of the parameter | Description | |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | *type | DNS record type [of types Aand AAAAand CNAMEand MXand NSand CAAand TXTand SPFand PTRand SRV] | | *host | The Host value of the DNS record | | *content | The Host value of the DNS record | | ttl | TTL of DNS record [default: 0] | | cloud | This parameter specifies whether the traffic of this record passes through the cloud or not [Default: false] | | priority | Priority of MX and SRV records [Default: 0] | | service | SRV record service | | protocol | SRV record protocol [default: _tcp] | | weight | SRV Record Weight [Default: 0] | | port | Priority of MX and SRV records [Default: 0] | | advanced | This parameter specifies whether this record has advanced settings or not [default: false] | | upstreamPort | Upstream Port of DNS record [Default: 80] | | upstreamProtocol | Upstream protocol related to DNS records. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. [Default: http] | | customSSLType | Custom SSL related DNS record. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. | #### Errors | type error | Error code | |--------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | DNSValidationError | 1008 | #### Example ```bash curl -X PUT --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords \ -F type="A" \ -F host="app" \ -F content="1.2.3.4" ``` ### GET: Get the information of a single DNS record ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId` #### Errors | type error | Error code | |---------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | RecordNotFoundError | 1021 | #### Example ```bash curl -X GET --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId ``` ### PATCH: Edit the parameters of a DNS record `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId` #### parameters | The name of the parameter | Description | |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | type | DNS record type [of types Aand AAAAand CNAMEand MXand NSand CAAand TXTand SPFand PTRand SRV] | | host | The Host value of the DNS record | | content | The Host value of the DNS record | | ttl | TTL of DNS record [default: 0] | | cloud | This parameter specifies whether the traffic of this record passes through the cloud or not [Default: false] | | priority | Priority of MX and SRV records [Default: 0] | | service | SRV record service | | protocol | SRV record protocol [default: _tcp] | | weight | SRV Record Weight [Default: 0] | | port | Priority of MX and SRV records [Default: 0] | | advanced | This parameter specifies whether this record has advanced settings or not [default: false] | | upstreamPort | Upstream Port of DNS record [Default: 80] | | upstreamProtocol | Upstream protocol related to DNS records. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. [Default: http] | | customSSLType | Custom SSL related DNS record. Note that if you change these settings for another record of the same subdomain, the settings will be overwritten. | #### Errors | type error | Error code | |---------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | RecordNotFoundError | 1021 | | DNSValidationError | 1008 | #### Example ```bash curl -X PATCH --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId \ -F cloud="true" ``` ### DELETE: Delete a DNS record ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId` #### Errors | type error | Error code | |---------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | | RecordNotFoundError | 1021 | #### Example ```bash curl -X DELETE --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/dnsrecords/:recordId ``` --- ## Cache clearing (API) ### POST: Clearing (Purge Cache) specified parameters, if no parameter is specified, the entire cache is deleted. ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge` #### parameters | The name of the parameter | Description | |---------------------------|-------------------------------------| | hostname | The hostname to be deleted | | hostnames | An array of hostnames to be cleared | | url | The URL to be deleted | | urls | An array of URLs to be purged | #### Errors | type error | Error code | |-------------------|------------| | ForbiddenError | 1003 | | RateLimitExceeded | 1013 | #### Examples Purge URLS: ```bash curl -X POST --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge \ -F urls[]="https://www.derak.cloud/post/1" \ -F urls[]="https://www.derak.cloud/post/2" ``` Purge HOSTNAMES: ```bash curl -X POST --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge \ -F hostnames[]="www.derak.cloud" \ -F hostnames[]="app.derak.cloud" ``` Purge EVERYTHING: ```bash curl -X POST --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/cache/purge ``` --- ## API for SSL certificates ### PUT: Enable SSL for a domain ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/` #### Errors | type error | Error code | |----------------|------------| | ForbiddenError | 1003 | #### Example ```bash curl -X PUT --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/ ``` ### DELETE: Disable SSL for a domain ex: `https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/` #### Errors | type error | Error code | |----------------|------------| | ForbiddenError | 1003 | #### Example ```bash curl -X DELETE --user "api:YOUR_API_KEY" \ https://api.derak.cloud/v1.0/zones/47c0ecf6c91243308c649ad1d2d618dd/ssl/ ``` ================================================ FILE: providers/dns/derak/internal/types.go ================================================ package internal import "time" type GetRecordsParameters struct { DNSType string `url:"dnsType,omitempty"` Content string `url:"content,omitempty"` } type GetRecordsResponse struct { Data []Record `json:"data"` Count int `json:"count"` } type Record struct { Type string `json:"type,omitempty"` Host string `json:"host,omitempty"` Content string `json:"content,omitempty"` ID string `json:"recordId,omitempty"` TTL int `json:"ttl,omitempty"` Cloud bool `json:"cloud,omitempty"` Priority int `json:"priority,omitempty"` Service string `json:"service,omitempty"` Protocol string `json:"protocol,omitempty"` Weight int `json:"weight,omitempty"` Port int `json:"port,omitempty"` Advanced bool `json:"advanced,omitempty"` UpstreamPort int `json:"upstreamPort,omitempty"` UpstreamProtocol string `json:"upstreamProtocol,omitempty"` CustomSSLType string `json:"customSSLType,omitempty"` } type APIResponse[T any] struct { Success bool `json:"success"` Result T `json:"result"` Error int `json:"error"` } type Zone struct { ID string `json:"zoneId,omitempty"` Tags []string `json:"tags,omitempty"` ContextID string `json:"contextId,omitempty"` ContextType string `json:"contextType,omitempty"` HumanReadable string `json:"humanReadable,omitempty"` Serial string `json:"serial,omitempty"` CreationTime int64 `json:"creationTime,omitempty"` CreationTimeDate time.Time `json:"creationTimeDate,omitzero"` Status string `json:"status,omitempty"` IsMoved bool `json:"is_moved,omitempty"` Paused bool `json:"paused,omitempty"` ServiceType string `json:"serviceType,omitempty"` Limbo bool `json:"limbo,omitempty"` TeamName string `json:"teamName,omitempty"` TeamID string `json:"teamId,omitempty"` MyTeam bool `json:"myTeam,omitempty"` RoleName string `json:"roleName,omitempty"` IsBoard bool `json:"isBoard,omitempty"` BoardRole []string `json:"boardRole,omitempty"` } func codeText(code int) string { switch code { case 1008: return "DNSValidationError" case 1003: return "ForbiddenError" case 1013: return "RateLimitExceeded" case 1021: return "RecordNotFoundError" default: return "" } } ================================================ FILE: providers/dns/desec/desec.go ================================================ // Package desec implements a DNS provider for solving the DNS-01 challenge using deSEC DNS. package desec import ( "context" "errors" "fmt" "log" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" "github.com/nrdcg/desec" ) // Environment variables names. const ( envNamespace = "DESEC_" EnvToken = envNamespace + "TOKEN" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // https://github.com/desec-io/desec-stack/issues/216 // https://desec.readthedocs.io/_/downloads/en/latest/pdf/ const defaultTTL int = 3600 var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { Token string PropagationTimeout time.Duration PollingInterval 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, 120*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *desec.Client } // NewDNSProvider returns a DNSProvider instance configured for deSEC. // Credentials must be passed in the environment variable: DESEC_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvToken) if err != nil { return nil, fmt.Errorf("desec: %w", err) } config := NewDefaultConfig() config.Token = values[EnvToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for deSEC. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("desec: the configuration of the DNS provider is nil") } if config.Token == "" { return nil, errors.New("desec: incomplete credentials, missing token") } opts := desec.NewDefaultClientOptions() if config.HTTPClient != nil { opts.HTTPClient = config.HTTPClient } opts.HTTPClient = clientdebug.Wrap(opts.HTTPClient) opts.Logger = log.Default() client := desec.New(config.Token, opts) return &DNSProvider{config: config, client: client}, 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 using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("desec: %w", err) } domainName := dns01.UnFqdn(authZone) quotedValue := fmt.Sprintf(`%q`, info.Value) rrSet, err := d.client.Records.Get(ctx, domainName, recordName, "TXT") if err != nil { var nf *desec.NotFoundError if !errors.As(err, &nf) { return fmt.Errorf("desec: failed to get records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } // Not found case -> create _, err = d.client.Records.Create(ctx, desec.RRSet{ Domain: domainName, SubName: recordName, Type: "TXT", Records: []string{quotedValue}, TTL: d.config.TTL, }) if err != nil { return fmt.Errorf("desec: failed to create records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } return nil } // update records := append(rrSet.Records, quotedValue) _, err = d.client.Records.Update(ctx, domainName, recordName, "TXT", desec.RRSet{Records: records}) if err != nil { return fmt.Errorf("desec: failed to update records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err) } recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("desec: %w", err) } domainName := dns01.UnFqdn(authZone) rrSet, err := d.client.Records.Get(ctx, domainName, recordName, "TXT") if err != nil { return fmt.Errorf("desec: failed to get records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } records := make([]string, 0) for _, record := range rrSet.Records { if record != fmt.Sprintf(`%q`, info.Value) { records = append(records, record) } } _, err = d.client.Records.Update(ctx, domainName, recordName, "TXT", desec.RRSet{Records: records}) if err != nil { return fmt.Errorf("desec: failed to update records: domainName=%s, recordName=%s: %w", domainName, recordName, err) } return nil } ================================================ FILE: providers/dns/desec/desec.toml ================================================ Name = "deSEC.io" Description = '''''' URL = "https://desec.io" Code = "desec" Since = "v3.7.0" Example = ''' DESEC_TOKEN=x-xxxxxxxxxxxxxxxxxxxxxxxxxx \ lego --dns desec -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DESEC_TOKEN = "Domain token" [Configuration.Additional] DESEC_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 4)" DESEC_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 120)" DESEC_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 3600)" DESEC_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://desec.readthedocs.io/en/latest/" ================================================ FILE: providers/dns/desec/desec_test.go ================================================ package desec import ( "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvToken).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvToken: "", }, expected: "desec: some credentials information are missing: DESEC_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 token string }{ { desc: "success", token: "api_key", }, { desc: "missing credentials", expected: "desec: incomplete credentials, missing token", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Token = test.token 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) } ================================================ FILE: providers/dns/designate/designate.go ================================================ // Package designate implements a DNS provider for solving the DNS-01 challenge using the Designate DNSaaS for Openstack. package designate import ( "errors" "fmt" "log" "os" "slices" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/gophercloud/gophercloud" "github.com/gophercloud/gophercloud/openstack" "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets" "github.com/gophercloud/gophercloud/openstack/dns/v2/zones" "github.com/gophercloud/utils/openstack/clientconfig" ) // Environment variables names. const ( envNamespace = "DESIGNATE_" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvZoneName = envNamespace + "ZONE_NAME" envNamespaceClient = "OS_" EnvAuthURL = envNamespaceClient + "AUTH_URL" EnvUsername = envNamespaceClient + "USERNAME" EnvPassword = envNamespaceClient + "PASSWORD" EnvUserID = envNamespaceClient + "USER_ID" EnvAppCredID = envNamespaceClient + "APPLICATION_CREDENTIAL_ID" EnvAppCredName = envNamespaceClient + "APPLICATION_CREDENTIAL_NAME" EnvAppCredSecret = envNamespaceClient + "APPLICATION_CREDENTIAL_SECRET" EnvTenantName = envNamespaceClient + "TENANT_NAME" EnvRegionName = envNamespaceClient + "REGION_NAME" EnvProjectID = envNamespaceClient + "PROJECT_ID" EnvCloud = envNamespaceClient + "CLOUD" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { ZoneName string PropagationTimeout time.Duration PollingInterval time.Duration TTL int opts gophercloud.AuthOptions } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, 10), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *gophercloud.ServiceClient dnsEntriesMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Designate. // Credentials must be passed in the environment variables: // OS_AUTH_URL, OS_USERNAME, OS_PASSWORD, OS_REGION_NAME. // Or you can specify OS_CLOUD to read the credentials from the according cloud entry. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() val, err := env.Get(EnvCloud) if err == nil { opts, erro := clientconfig.AuthOptions(&clientconfig.ClientOpts{ Cloud: val[EnvCloud], }) if erro != nil { return nil, fmt.Errorf("designate: %w", erro) } config.opts = *opts } else { opts, err := openstack.AuthOptionsFromEnv() if err != nil { return nil, fmt.Errorf("designate: %w", err) } config.opts = opts } return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Designate. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("designate: the configuration of the DNS provider is nil") } provider, err := openstack.AuthenticatedClient(config.opts) if err != nil { return nil, fmt.Errorf("designate: failed to authenticate: %w", err) } dnsClient, err := openstack.NewDNSV2(provider, gophercloud.EndpointOpts{ Region: os.Getenv("OS_REGION_NAME"), }) if err != nil { return nil, fmt.Errorf("designate: failed to get DNS provider: %w", err) } return &DNSProvider{client: dnsClient, 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 { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: %w", err) } zoneID, err := d.getZoneID(zone) if err != nil { return fmt.Errorf("designate: couldn't get zone ID in Present: %w", err) } // use mutex to prevent race condition between creating the record and verifying it d.dnsEntriesMu.Lock() defer d.dnsEntriesMu.Unlock() existingRecord, err := d.getRecord(zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: %w", err) } if existingRecord != nil { if slices.Contains(existingRecord.Records, info.Value) { log.Printf("designate: the record already exists: %s", info.Value) return nil } return d.updateRecord(existingRecord, info.Value) } err = d.createRecord(zoneID, info.EffectiveFQDN, info.Value) if err != nil { return fmt.Errorf("designate: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: %w", err) } zoneID, err := d.getZoneID(zone) if err != nil { return fmt.Errorf("designate: couldn't get zone ID in CleanUp: %w", err) } // use mutex to prevent race condition between getting the record and deleting it d.dnsEntriesMu.Lock() defer d.dnsEntriesMu.Unlock() record, err := d.getRecord(zoneID, info.EffectiveFQDN) if err != nil { return fmt.Errorf("designate: couldn't get Record ID in CleanUp: %w", err) } if record == nil { // Record is already deleted return nil } err = recordsets.Delete(d.client, zoneID, record.ID).ExtractErr() if err != nil { return fmt.Errorf("designate: error for %s in CleanUp: %w", info.EffectiveFQDN, err) } return nil } func (d *DNSProvider) createRecord(zoneID, fqdn, value string) error { createOpts := recordsets.CreateOpts{ Name: fqdn, Type: "TXT", TTL: d.config.TTL, Description: "ACME verification record", Records: []string{value}, } actual, err := recordsets.Create(d.client, zoneID, createOpts).Extract() if err != nil { return fmt.Errorf("error for %s in Present while creating record: %w", fqdn, err) } if actual.Name != fqdn || actual.TTL != d.config.TTL { return errors.New("the created record doesn't match what we wanted to create") } return nil } func (d *DNSProvider) updateRecord(record *recordsets.RecordSet, value string) error { if slices.Contains(record.Records, value) { log.Printf("skip: the record already exists: %s", value) return nil } values := append([]string{value}, record.Records...) updateOpts := recordsets.UpdateOpts{ Description: &record.Description, TTL: &record.TTL, Records: values, } result := recordsets.Update(d.client, record.ZoneID, record.ID, updateOpts) return result.Err } func (d *DNSProvider) getZoneID(wanted string) (string, error) { listOpts := zones.ListOpts{ Name: wanted, } allPages, err := zones.List(d.client, listOpts).AllPages() if err != nil { return "", err } allZones, err := zones.ExtractZones(allPages) if err != nil { return "", err } for _, zone := range allZones { if zone.Name == wanted { return zone.ID, nil } } return "", fmt.Errorf("zone id not found for %s", wanted) } func (d *DNSProvider) getRecord(zoneID, wanted string) (*recordsets.RecordSet, error) { listOpts := recordsets.ListOpts{ Name: wanted, Type: "TXT", } allPages, err := recordsets.ListByZone(d.client, zoneID, listOpts).AllPages() if err != nil { return nil, err } allRecords, err := recordsets.ExtractRecordSets(allPages) if err != nil { return nil, err } for _, record := range allRecords { if record.Name == wanted && record.Type == "TXT" { return &record, nil } } return nil, nil } func (d *DNSProvider) getZoneName(fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) } if authZone == "" { return "", errors.New("empty zone name") } return authZone, nil } ================================================ FILE: providers/dns/designate/designate.toml ================================================ Name = "Designate DNSaaS for Openstack" Description = '''''' URL = "https://docs.openstack.org/designate/latest/" Code = "designate" Since = "v2.2.0" Example = ''' # With a `clouds.yaml` OS_CLOUD=my_openstack \ lego --dns designate -d '*.example.com' -d example.com run # or OS_AUTH_URL=https://openstack.example.org \ OS_REGION_NAME=RegionOne \ OS_PROJECT_ID=23d4522a987d4ab529f722a007c27846 OS_USERNAME=myuser \ OS_PASSWORD=passw0rd \ lego --dns designate -d '*.example.com' -d example.com run # or OS_AUTH_URL=https://openstack.example.org \ OS_REGION_NAME=RegionOne \ OS_AUTH_TYPE=v3applicationcredential \ OS_APPLICATION_CREDENTIAL_ID=imn74uq0or7dyzz20dwo1ytls4me8dry \ OS_APPLICATION_CREDENTIAL_SECRET=68FuSPSdQqkFQYH5X1OoriEIJOwyLtQ8QSqXZOc9XxFK1A9tzZT6He2PfPw0OMja \ lego --dns designate -d '*.example.com' -d example.com run ''' Additional = ''' ## Description There are three main ways of authenticating with Designate: 1. The first one is by using the `OS_CLOUD` environment variable and a `clouds.yaml` file. 2. The second one is using your username and password, via the `OS_USERNAME`, `OS_PASSWORD` and `OS_PROJECT_NAME` environment variables. 3. The third one is by using an application credential, via the `OS_APPLICATION_CREDENTIAL_*` and `OS_USER_ID` environment variables. For the username/password and application methods, the `OS_AUTH_URL` and `OS_REGION_NAME` environment variables are required. For more information, you can read about the different methods of authentication with OpenStack in the Keystone's documentation and the gophercloud documentation: - [Keystone username/password](https://docs.openstack.org/keystone/latest/user/supported_clients.html) - [Keystone application credentials](https://docs.openstack.org/keystone/latest/user/application_credentials.html) Public cloud providers with support for Designate: - [Fuga Cloud](https://fuga.cloud/) ''' [Configuration] [Configuration.Credentials] OS_AUTH_URL = "Identity endpoint URL" OS_USERNAME = "Username" OS_PASSWORD = "Password" OS_USER_ID = "User ID" OS_APPLICATION_CREDENTIAL_ID = "Application credential ID" OS_APPLICATION_CREDENTIAL_NAME = "Application credential name" OS_APPLICATION_CREDENTIAL_SECRET = "Application credential secret" OS_PROJECT_NAME = "Project name" OS_REGION_NAME = "Region name" [Configuration.Additional] OS_PROJECT_ID = "Project ID" OS_TENANT_NAME = "Tenant name (deprecated see OS_PROJECT_NAME and OS_PROJECT_ID)" DESIGNATE_ZONE_NAME = "The zone name to use in the OpenStack Project to manage TXT records." DESIGNATE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" DESIGNATE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 600)" DESIGNATE_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 10)" [Links] API = "https://docs.openstack.org/designate/latest/" GoClient = "https://pkg.go.dev/github.com/gophercloud/gophercloud/openstack/dns/v2" ================================================ FILE: providers/dns/designate/designate_test.go ================================================ package designate import ( "net/http" "net/http/httptest" "os" "testing" "time" "github.com/go-acme/lego/v4/platform/tester" "github.com/gophercloud/utils/openstack/clientconfig" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) const ( envDomain = envNamespace + "DOMAIN" envOSClientConfigFile = "OS_CLIENT_CONFIG_FILE" ) var envTest = tester.NewEnvTest( EnvCloud, EnvAuthURL, EnvUsername, EnvPassword, EnvUserID, EnvAppCredID, EnvAppCredName, EnvAppCredSecret, EnvTenantName, EnvRegionName, EnvProjectID, envOSClientConfigFile). WithDomain(envDomain) func TestNewDNSProvider_fromEnv(t *testing.T) { serverURL := setupTestProvider(t) testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvUsername: "B", EnvPassword: "C", EnvRegionName: "D", EnvProjectID: "E", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthURL: "", EnvUsername: "", EnvPassword: "", EnvRegionName: "", }, expected: "designate: Missing environment variable [OS_AUTH_URL]", }, { desc: "missing auth url", envVars: map[string]string{ EnvAuthURL: "", EnvUsername: "B", EnvPassword: "C", EnvRegionName: "D", }, expected: "designate: Missing environment variable [OS_AUTH_URL]", }, { desc: "missing username", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvUsername: "", EnvPassword: "C", EnvRegionName: "D", }, expected: "designate: Missing one of the following environment variables [OS_USERID, OS_USERNAME]", }, { desc: "missing password", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvUsername: "B", EnvPassword: "", EnvRegionName: "D", }, expected: "designate: Missing environment variable [OS_PASSWORD]", }, { desc: "missing application credential secret", envVars: map[string]string{ EnvAuthURL: serverURL + "/v2.0/", EnvRegionName: "D", EnvAppCredID: "F", }, expected: "designate: Missing environment variable [OS_APPLICATION_CREDENTIAL_SECRET]", }, } 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 TestNewDNSProvider_fromCloud(t *testing.T) { serverURL := setupTestProvider(t) testCases := []struct { desc string osCloud string cloud clientconfig.Cloud expected string }{ { desc: "success", osCloud: "good_cloud", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ AuthURL: serverURL + "/v2.0/", Username: "B", Password: "C", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, }, { desc: "missing auth url", osCloud: "missing_auth_url", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ Username: "B", Password: "C", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, expected: "designate: Missing input for argument [auth_url]", }, { desc: "missing username", osCloud: "missing_username", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ AuthURL: serverURL + "/v2.0/", Password: "C", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, expected: "designate: failed to authenticate: Missing input for argument [Username]", }, { desc: "missing password", osCloud: "missing_auth_url", cloud: clientconfig.Cloud{ AuthInfo: &clientconfig.AuthInfo{ AuthURL: serverURL + "/v2.0/", Username: "B", ProjectName: "E", ProjectID: "F", }, RegionName: "D", }, expected: "designate: failed to authenticate: Exactly one of PasswordCredentials and TokenCredentials must be provided", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() envTest.Apply(map[string]string{ EnvCloud: test.osCloud, envOSClientConfigFile: createCloudsYaml(t, test.osCloud, test.cloud), }) 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) { serverURL := setupTestProvider(t) testCases := []struct { desc string tenantName string password string userName string authURL string expected string }{ { desc: "success", tenantName: "A", password: "B", userName: "C", authURL: serverURL + "/v2.0/", }, { desc: "wrong auth url", tenantName: "A", password: "B", userName: "C", authURL: serverURL, expected: "designate: failed to authenticate: No supported version available from endpoint " + serverURL + "/", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.opts.TenantName = test.tenantName config.opts.Password = test.password config.opts.Username = test.userName config.opts.IdentityEndpoint = test.authURL 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) } }) } } // createCloudsYaml creates a temporary cloud file for testing purpose. func createCloudsYaml(t *testing.T, cloudName string, cloud clientconfig.Cloud) string { t.Helper() file, err := os.CreateTemp(t.TempDir(), "lego_test") require.NoError(t, err) t.Cleanup(func() { _ = file.Close() }) clouds := clientconfig.Clouds{ Clouds: map[string]clientconfig.Cloud{ cloudName: cloud, }, } err = yaml.NewEncoder(file).Encode(&clouds) require.NoError(t, err) return file.Name() } func setupTestProvider(t *testing.T) string { t.Helper() mux := http.NewServeMux() server := httptest.NewServer(mux) t.Cleanup(server.Close) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{ "access": { "token": { "id": "a", "expires": "9015-06-05T16:24:57.637Z" }, "user": { "name": "a", "roles": [ ], "role_links": [ ] }, "serviceCatalog": [ { "endpoints": [ { "adminURL": "http://23.253.72.207:9696/", "region": "D", "internalURL": "http://23.253.72.207:9696/", "id": "97c526db8d7a4c88bbb8d68db1bdcdb8", "publicURL": "http://23.253.72.207:9696/" } ], "endpoints_links": [ ], "type": "dns", "name": "designate" } ] } }`)) w.WriteHeader(http.StatusOK) }) return server.URL } 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) } ================================================ FILE: providers/dns/digitalocean/digitalocean.go ================================================ // Package digitalocean implements a DNS provider for solving the DNS-01 challenge using digitalocean DNS. package digitalocean import ( "context" "errors" "fmt" "net/http" "net/url" "sync" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/digitalocean/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DO_" EnvAuthToken = envNamespace + "AUTH_TOKEN" EnvAPIUrl = envNamespace + "API_URL" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string AuthToken string TTL int PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: env.GetOrDefaultString(EnvAPIUrl, internal.DefaultBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), 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]int recordIDsMu sync.Mutex } // NewDNSProvider returns a DNSProvider instance configured for Digital // Ocean. Credentials must be passed in the environment variable: // DO_AUTH_TOKEN. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAuthToken) if err != nil { return nil, fmt.Errorf("digitalocean: %w", err) } config := NewDefaultConfig() config.AuthToken = values[EnvAuthToken] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for Digital Ocean. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("digitalocean: the configuration of the DNS provider is nil") } if config.AuthToken == "" { return nil, errors.New("digitalocean: credentials missing") } client := internal.NewClient( clientdebug.Wrap( internal.OAuthStaticAccessToken(config.HTTPClient, config.AuthToken), ), ) if config.BaseURL != "" { var err error client.BaseURL, err = url.Parse(config.BaseURL) if err != nil { return nil, fmt.Errorf("digitalocean: %w", err) } } return &DNSProvider{ config: config, client: client, recordIDs: make(map[string]int), }, 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 using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("digitalocean: could not find zone for domain %q: %w", domain, err) } record := internal.Record{Type: "TXT", Name: info.EffectiveFQDN, Data: info.Value, TTL: d.config.TTL} respData, err := d.client.AddTxtRecord(context.Background(), authZone, record) if err != nil { return fmt.Errorf("digitalocean: %w", err) } d.recordIDsMu.Lock() d.recordIDs[token] = respData.DomainRecord.ID d.recordIDsMu.Unlock() return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("digitalocean: could not find zone for domain %q: %w", domain, err) } // 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("digitalocean: unknown record ID for '%s'", info.EffectiveFQDN) } err = d.client.RemoveTxtRecord(context.Background(), authZone, recordID) if err != nil { return fmt.Errorf("digitalocean: %w", err) } // Delete record ID from map d.recordIDsMu.Lock() delete(d.recordIDs, token) d.recordIDsMu.Unlock() return nil } ================================================ FILE: providers/dns/digitalocean/digitalocean.toml ================================================ Name = "Digital Ocean" Description = '''''' URL = "https://www.digitalocean.com/docs/networking/dns/" Code = "digitalocean" Since = "v0.3.0" Example = ''' DO_AUTH_TOKEN=xxxxxx \ lego --dns digitalocean -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DO_AUTH_TOKEN = "Authentication token" [Configuration.Additional] DO_API_URL = "The URL of the API" DO_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" DO_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DO_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" DO_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://developers.digitalocean.com/documentation/v2/#domain-records" ================================================ FILE: providers/dns/digitalocean/digitalocean_test.go ================================================ package digitalocean import ( "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest(EnvAuthToken) func mockProvider() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.AuthToken = "asdf1234" config.BaseURL = server.URL config.HTTPClient = server.Client() return NewDNSProviderConfig(config) }, servermock.CheckHeader(). WithJSONHeaders(). With("Authorization", "Bearer asdf1234")) } func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAuthToken: "123", }, }, { desc: "missing credentials", envVars: map[string]string{ EnvAuthToken: "", }, expected: "digitalocean: some credentials information are missing: DO_AUTH_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) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string authToken string expected string }{ { desc: "success", authToken: "123", }, { desc: "missing credentials", expected: "digitalocean: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.AuthToken = test.authToken p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.recordIDs) } else { require.EqualError(t, err, test.expected) } }) } } func TestDNSProvider_Present(t *testing.T) { provider := mockProvider(). Route("POST /v2/domains/example.com/records", servermock.RawStringResponse(`{ "domain_record": { "id": 1234567, "type": "TXT", "name": "_acme-challenge", "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", "priority": null, "port": null, "weight": null } }`). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). Build(t) err := provider.Present("example.com", "", "foobar") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockProvider(). Route("DELETE /v2/domains/example.com/records/1234567", servermock.Noop(). WithStatusCode(http.StatusNoContent)). Build(t) provider.recordIDsMu.Lock() provider.recordIDs["token"] = 1234567 provider.recordIDsMu.Unlock() err := provider.CleanUp("example.com", "token", "") require.NoError(t, err) } ================================================ FILE: providers/dns/digitalocean/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "golang.org/x/oauth2" ) // DefaultBaseURL default API endpoint. const DefaultBaseURL = "https://api.digitalocean.com" // Client the Digital Ocean API client. type Client struct { BaseURL *url.URL httpClient *http.Client } // NewClient creates a new Client. func NewClient(hc *http.Client) *Client { baseURL, _ := url.Parse(DefaultBaseURL) if hc == nil { hc = &http.Client{Timeout: 5 * time.Second} } return &Client{BaseURL: baseURL, httpClient: hc} } func (c *Client) AddTxtRecord(ctx context.Context, zone string, record Record) (*TxtRecordResponse, error) { endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records") req, err := newJSONRequest(ctx, http.MethodPost, endpoint, record) if err != nil { return nil, err } respData := &TxtRecordResponse{} err = c.do(req, respData) if err != nil { return nil, err } return respData, nil } func (c *Client) RemoveTxtRecord(ctx context.Context, zone string, recordID int) error { endpoint := c.BaseURL.JoinPath("v2", "domains", dns01.UnFqdn(zone), "records", strconv.Itoa(recordID)) req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) do(req *http.Request, result any) error { resp, err := c.httpClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= http.StatusBadRequest { return parseError(req, resp) } if result == nil { return nil } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") // NOTE: Even though the body is empty, DigitalOcean API docs still show setting this Content-Type... req.Header.Set("Content-Type", "application/json") return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errInfo APIError err := json.Unmarshal(raw, &errInfo) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, errInfo) } func OAuthStaticAccessToken(client *http.Client, accessToken string) *http.Client { if client == nil { client = &http.Client{Timeout: 5 * time.Second} } client.Transport = &oauth2.Transport{ Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}), Base: client.Transport, } return client } ================================================ FILE: providers/dns/digitalocean/internal/client_test.go ================================================ package internal import ( "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client := NewClient(OAuthStaticAccessToken(server.Client(), "secret")) client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader().WithJSONHeaders(). WithAuthorization("Bearer secret")) } func TestClient_AddTxtRecord(t *testing.T) { client := mockBuilder(). Route("POST /v2/domains/example.com/records", servermock.ResponseFromFixture("domains-records_POST.json"). WithStatusCode(http.StatusCreated), servermock.CheckRequestJSONBody(`{"type":"TXT","name":"_acme-challenge.example.com.","data":"w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI","ttl":30}`)). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge.example.com.", Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", TTL: 30, } newRecord, err := client.AddTxtRecord(t.Context(), "example.com", record) require.NoError(t, err) expected := &TxtRecordResponse{DomainRecord: Record{ ID: 1234567, Type: "TXT", Name: "_acme-challenge", Data: "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", TTL: 0, }} assert.Equal(t, expected, newRecord) } func TestClient_RemoveTxtRecord(t *testing.T) { client := mockBuilder(). Route("DELETE /v2/domains/example.com/records/1234567", servermock.ResponseFromFixture("domains-records_POST.json"). WithStatusCode(http.StatusNoContent)). Build(t) err := client.RemoveTxtRecord(t.Context(), "example.com", 1234567) require.NoError(t, err) } ================================================ FILE: providers/dns/digitalocean/internal/fixtures/domains-records_POST.json ================================================ { "domain_record": { "id": 1234567, "type": "TXT", "name": "_acme-challenge", "data": "w6uP8Tcg6K2QR905Rms8iXTlksL6OD1KOWBxTK7wxPI", "priority": null, "port": null, "weight": null } } ================================================ FILE: providers/dns/digitalocean/internal/types.go ================================================ package internal import "fmt" // TxtRecordResponse represents a response from DO's API after making a TXT record. type TxtRecordResponse struct { DomainRecord Record `json:"domain_record"` } type Record struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Data string `json:"data,omitempty"` TTL int `json:"ttl,omitempty"` } type APIError struct { ID string `json:"id"` Message string `json:"message"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.ID, a.Message) } ================================================ FILE: providers/dns/directadmin/directadmin.go ================================================ package directadmin import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/directadmin/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DIRECTADMIN_" EnvAPIURL = envNamespace + "API_URL" EnvUsername = envNamespace + "USERNAME" EnvPassword = envNamespace + "PASSWORD" EnvZoneName = envNamespace + "ZONE_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) var _ challenge.ProviderTimeout = (*DNSProvider)(nil) // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string Username string Password string ZoneName string TTL int PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ ZoneName: env.GetOrFile(EnvZoneName), TTL: env.GetOrDefaultInt(EnvTTL, 30), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 60*time.Second), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { client *internal.Client config *Config } // NewDNSProvider returns a DNSProvider instance configured for DirectAdmin. // Credentials must be passed in the environment variables: // DIRECTADMIN_API_URL, DIRECTADMIN_USERNAME, DIRECTADMIN_PASSWORD. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIURL, EnvUsername, EnvPassword) if err != nil { return nil, fmt.Errorf("directadmin: %w", err) } config := NewDefaultConfig() config.BaseURL = values[EnvAPIURL] config.Username = values[EnvUsername] config.Password = values[EnvPassword] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DirectAdmin. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config.BaseURL == "" { return nil, errors.New("directadmin: missing API URL") } if config.Username == "" || config.Password == "" { return nil, errors.New("directadmin: some credentials information are missing") } client, err := internal.NewClient(config.BaseURL, config.Username, config.Password) if err != nil { return nil, fmt.Errorf("directadmin: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{client: client, 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 using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("directadmin: [domain: %q] %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("directadmin: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Value: info.Value, TTL: d.config.TTL, } err = d.client.SetRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("directadmin: set record for zone %s and subdomain %s: %w", authZone, subDomain, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := d.getZoneName(info.EffectiveFQDN) if err != nil { return fmt.Errorf("directadmin: [domain: %q] %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("directadmin: %w", err) } record := internal.Record{ Name: subDomain, Type: "TXT", Value: info.Value, } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("directadmin: delete record for zone %s and subdomain %s: %w", authZone, subDomain, err) } return nil } func (d *DNSProvider) getZoneName(fqdn string) (string, error) { if d.config.ZoneName != "" { return d.config.ZoneName, nil } authZone, err := dns01.FindZoneByFqdn(fqdn) if err != nil { return "", fmt.Errorf("could not find zone for %s: %w", fqdn, err) } if authZone == "" { return "", errors.New("empty zone name") } return authZone, nil } ================================================ FILE: providers/dns/directadmin/directadmin.toml ================================================ Name = "DirectAdmin" Description = '''''' URL = "https://www.directadmin.com" Code = "directadmin" Since = "v4.18.0" Example = ''' DIRECTADMIN_API_URL="http://example.com:2222" \ DIRECTADMIN_USERNAME=xxxx \ DIRECTADMIN_PASSWORD=yyy \ lego --dns directadmin -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DIRECTADMIN_API_URL = "URL of the API" DIRECTADMIN_USERNAME = "API username" DIRECTADMIN_PASSWORD = "API password" [Configuration.Additional] DIRECTADMIN_ZONE_NAME = "Zone name used to add the TXT record" DIRECTADMIN_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 5)" DIRECTADMIN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 60)" DIRECTADMIN_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 30)" DIRECTADMIN_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://www.directadmin.com/api.php" ================================================ FILE: providers/dns/directadmin/directadmin_test.go ================================================ package directadmin import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIURL, EnvUsername, EnvPassword).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIURL: "https://example.com:2222", EnvUsername: "test", EnvPassword: "secret", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "directadmin: some credentials information are missing: DIRECTADMIN_API_URL,DIRECTADMIN_USERNAME,DIRECTADMIN_PASSWORD", }, { desc: "missing API URL", envVars: map[string]string{ EnvUsername: "test", EnvPassword: "secret", }, expected: "directadmin: some credentials information are missing: DIRECTADMIN_API_URL", }, { desc: "missing username", envVars: map[string]string{ EnvAPIURL: "https://example.com:2222", EnvPassword: "secret", }, expected: "directadmin: some credentials information are missing: DIRECTADMIN_USERNAME", }, { desc: "missing password", envVars: map[string]string{ EnvAPIURL: "https://example.com:2222", EnvUsername: "test", }, expected: "directadmin: some credentials information are missing: DIRECTADMIN_PASSWORD", }, } 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.client) require.NotNil(t, p.config) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string baseURL string username string password string expected string }{ { desc: "success", baseURL: "https://example.com", username: "test", password: "secret", }, { desc: "missing API URL", expected: "directadmin: missing API URL", }, { desc: "missing username", baseURL: "https://example.com", expected: "directadmin: some credentials information are missing", }, { desc: "missing password", baseURL: "https://example.com", username: "test", expected: "directadmin: some credentials information are missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.BaseURL = test.baseURL config.Username = test.username config.Password = test.password p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.client) 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) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/directadmin/internal/client.go ================================================ package internal import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" querystring "github.com/google/go-querystring/query" ) // Client the Direct Admin API client. type Client struct { baseURL *url.URL HTTPClient *http.Client username string password string } // NewClient creates a new Client. func NewClient(baseURL, username, password string) (*Client, error) { api, err := url.Parse(baseURL) if err != nil { return nil, err } return &Client{ baseURL: api, HTTPClient: &http.Client{Timeout: 10 * time.Second}, username: username, password: password, }, nil } func (c *Client) SetRecord(ctx context.Context, domain string, record Record) error { data, err := querystring.Values(record) if err != nil { return err } data.Set("action", "add") return c.do(ctx, domain, data) } func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { data, err := querystring.Values(record) if err != nil { return err } data.Set("action", "delete") return c.do(ctx, domain, data) } func (c *Client) do(ctx context.Context, domain string, data url.Values) error { endpoint := c.baseURL.JoinPath("CMD_API_DNS_CONTROL") query := endpoint.Query() query.Set("domain", domain) query.Set("json", "yes") endpoint.RawQuery = query.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(data.Encode())) if err != nil { return fmt.Errorf("unable to create request: %w", err) } req.SetBasicAuth(c.username, c.password) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return parseError(req, resp) } return nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errInfo APIError err := json.Unmarshal(raw, &errInfo) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return fmt.Errorf("[status code %d] %w", resp.StatusCode, errInfo) } ================================================ FILE: providers/dns/directadmin/internal/client_test.go ================================================ package internal import ( "fmt" "net/http" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder[*Client]( func(server *httptest.Server) (*Client, error) { client, _ := NewClient(server.URL, "user", "secret") client.HTTPClient = server.Client() return client, nil }, servermock.CheckHeader(). WithContentTypeFromURLEncoded()) } func newAPIError(reason string, a ...any) APIError { return APIError{ Message: "Cannot View Dns Record", Result: fmt.Sprintf(reason, a...), } } func TestClient_SetRecord(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", nil, servermock.CheckQueryParameter().Strict(). With("domain", "example.com"). With("json", "yes"), servermock.CheckForm().UsePostForm().Strict(). With("action", "add"). With("name", "foo"). With("type", "TXT"). With("value", "txtTXTtxt"). With("ttl", "123"), ). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", TTL: 123, } err := client.SetRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_SetRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", servermock.JSONEncode(newAPIError("OOPS")). WithStatusCode(http.StatusInternalServerError)). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", TTL: 123, } err := client.SetRecord(t.Context(), "example.com", record) require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", nil, servermock.CheckQueryParameter().Strict(). With("domain", "example.com"). With("json", "yes"), servermock.CheckForm().UsePostForm().Strict(). With("action", "delete"). With("name", "foo"). With("type", "TXT"). With("value", "txtTXTtxt"), ). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", } err := client.DeleteRecord(t.Context(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /CMD_API_DNS_CONTROL", servermock.JSONEncode(newAPIError("OOPS")). WithStatusCode(http.StatusInternalServerError)). Build(t) record := Record{ Name: "foo", Type: "TXT", Value: "txtTXTtxt", } err := client.DeleteRecord(t.Context(), "example.com", record) require.EqualError(t, err, "[status code 500] Cannot View Dns Record: OOPS") } ================================================ FILE: providers/dns/directadmin/internal/types.go ================================================ package internal import "fmt" // Record represents a DNS record. type Record struct { Name string `url:"name,omitempty"` Type string `url:"type,omitempty"` Value string `url:"value,omitempty"` TTL int `url:"ttl,omitempty"` } // APIError represents a API error. type APIError struct { Message string `json:"error,omitempty"` Result string `json:"result,omitempty"` } func (a APIError) Error() string { return fmt.Sprintf("%s: %s", a.Message, a.Result) } ================================================ FILE: providers/dns/dns_providers_test.go ================================================ package dns import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/providers/dns/exec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var envTest = tester.NewEnvTest("EXEC_PATH") func TestKnownDNSProviderSuccess(t *testing.T) { defer envTest.RestoreEnv() envTest.Apply(map[string]string{ "EXEC_PATH": "abc", }) provider, err := NewDNSChallengeProviderByName("exec") require.NoError(t, err) assert.NotNil(t, provider) assert.IsType(t, &exec.DNSProvider{}, provider, "The loaded DNS provider doesn't have the expected type.") } func TestKnownDNSProviderError(t *testing.T) { defer envTest.RestoreEnv() envTest.ClearEnv() provider, err := NewDNSChallengeProviderByName("exec") require.Error(t, err) assert.Nil(t, provider) } func TestUnknownDNSProvider(t *testing.T) { provider, err := NewDNSChallengeProviderByName("foobar") require.Error(t, err) assert.Nil(t, provider) } ================================================ FILE: providers/dns/dnsexit/dnsexit.go ================================================ // Package dnsexit implements a DNS provider for solving the DNS-01 challenge using DNSExit. package dnsexit import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dnsexit/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DNSEXIT_" EnvAPIKey = envNamespace + "API_KEY" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) // Config is used to configure the creation of the DNSProvider. type Config struct { APIKey string PropagationTimeout time.Duration PollingInterval time.Duration TTL int HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for DNSExit. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvAPIKey) if err != nil { return nil, fmt.Errorf("dnsexit: %w", err) } config := NewDefaultConfig() config.APIKey = values[EnvAPIKey] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for DNSExit. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnsexit: the configuration of the DNS provider is nil") } client, err := internal.NewClient(config.APIKey) if err != nil { return nil, fmt.Errorf("dnsexit: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{ config: config, client: client, }, nil } // Present creates a TXT record using the specified parameters. func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("dnsexit: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Content: info.Value, TTL: toMinutes(d.config.TTL), } err = d.client.AddRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("dnsexit: add record: %w", err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) if err != nil { return fmt.Errorf("dnsexit: could not find zone for domain %q: %w", domain, err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) if err != nil { return fmt.Errorf("dnsexit: %w", err) } record := internal.Record{ Type: "TXT", Name: subDomain, Content: info.Value, } err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), record) if err != nil { return fmt.Errorf("dnsexit: add record: %w", err) } return 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 } func toMinutes(seconds int) int { i := seconds / 60 if seconds%60 > 0 { i++ } return i } ================================================ FILE: providers/dns/dnsexit/dnsexit.toml ================================================ Name = "DNSExit" Description = '''''' URL = "https://dnsexit.com" Code = "dnsexit" Since = "v4.32.0" Example = ''' DNSEXIT_API_KEY="xxxxxxxxxxxxxxxxxxxxx" \ lego --dns dnsexit -d '*.example.com' -d example.com run ''' [Configuration] [Configuration.Credentials] DNSEXIT_API_KEY = "API key" [Configuration.Additional] DNSEXIT_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 10)" DNSEXIT_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)" DNSEXIT_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)" DNSEXIT_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" [Links] API = "https://dnsexit.com/dns/dns-api/" ================================================ FILE: providers/dns/dnsexit/dnsexit_test.go ================================================ package dnsexit import ( "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvAPIKey).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvAPIKey: "key", }, }, { desc: "missing credentials", envVars: map[string]string{}, expected: "dnsexit: some credentials information are missing: DNSEXIT_API_KEY", }, } 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) require.NotNil(t, p.client) } else { require.EqualError(t, err, test.expected) } }) } } func TestNewDNSProviderConfig(t *testing.T) { testCases := []struct { desc string apiKey string expected string }{ { desc: "success", apiKey: "key", }, { desc: "missing credentials", expected: "dnsexit: credentials missing", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.APIKey = test.apiKey p, err := NewDNSProviderConfig(config) if test.expected == "" { require.NoError(t, err) require.NotNil(t, p) require.NotNil(t, p.config) require.NotNil(t, p.client) } 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) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } func mockBuilder() *servermock.Builder[*DNSProvider] { return servermock.NewBuilder( func(server *httptest.Server) (*DNSProvider, error) { config := NewDefaultConfig() config.APIKey = "secret" config.HTTPClient = server.Client() p, err := NewDNSProviderConfig(config) if err != nil { return nil, err } p.client.BaseURL, _ = url.Parse(server.URL) return p, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("apikey", "secret"), ) } func TestDNSProvider_Present(t *testing.T) { provider := mockBuilder(). Route("POST /", servermock.ResponseFromInternal("success.json"), servermock.CheckRequestJSONBodyFromInternal("add_record-request.json"), ). Build(t) err := provider.Present("example.com", "abc", "123d==") require.NoError(t, err) } func TestDNSProvider_CleanUp(t *testing.T) { provider := mockBuilder(). Route("POST /", servermock.ResponseFromInternal("success.json"), servermock.CheckRequestJSONBodyFromInternal("delete_record-request.json"), ). Build(t) err := provider.CleanUp("example.com", "abc", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dnsexit/internal/client.go ================================================ package internal import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" ) const defaultBaseURL = "https://api.dnsexit.com/dns/" // Client the DNSExit API client. type Client struct { apiKey string BaseURL *url.URL HTTPClient *http.Client } // NewClient creates a new Client. func NewClient(apiKey string) (*Client, error) { if apiKey == "" { return nil, errors.New("credentials missing") } baseURL, _ := url.Parse(defaultBaseURL) return &Client{ apiKey: apiKey, BaseURL: baseURL, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } // AddRecord adds a record. // https://dnsexit.com/dns/dns-api/#example-add-spf // https://dnsexit.com/dns/dns-api/#example-lse func (c *Client) AddRecord(ctx context.Context, domain string, record Record) error { payload := APIRequest{ Domain: domain, Add: []Record{record}, } req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload) if err != nil { return err } err = c.do(req) if err != nil { return err } return nil } // DeleteRecord deletes a record. // https://dnsexit.com/dns/dns-api/#delete-a-record func (c *Client) DeleteRecord(ctx context.Context, domain string, record Record) error { payload := APIRequest{ Domain: domain, Delete: []Record{record}, } req, err := newJSONRequest(ctx, http.MethodPost, c.BaseURL, payload) if err != nil { return err } err = c.do(req) if err != nil { return err } return nil } func (c *Client) do(req *http.Request) error { useragent.SetHeader(req.Header) req.Header.Set("apikey", c.apiKey) resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode > http.StatusBadRequest { return parseError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } result := &APIResponse{} err = json.Unmarshal(raw, result) if err != nil { return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err) } if result.Code != 0 { return result } return nil } func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) { buf := new(bytes.Buffer) if payload != nil { err := json.NewEncoder(buf).Encode(payload) if err != nil { return nil, fmt.Errorf("failed to create request JSON body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf) if err != nil { return nil, fmt.Errorf("unable to create request: %w", err) } req.Header.Set("Accept", "application/json") if payload != nil { req.Header.Set("Content-Type", "application/json") } return req, nil } func parseError(req *http.Request, resp *http.Response) error { raw, _ := io.ReadAll(resp.Body) var errAPI APIResponse err := json.Unmarshal(raw, &errAPI) if err != nil { return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw) } return &errAPI } ================================================ FILE: providers/dns/dnsexit/internal/client_test.go ================================================ package internal import ( "context" "net/http" "net/http/httptest" "net/url" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func mockBuilder() *servermock.Builder[*Client] { return servermock.NewBuilder( func(server *httptest.Server) (*Client, error) { client, err := NewClient("secret") if err != nil { return nil, err } client.HTTPClient = server.Client() client.BaseURL, _ = url.Parse(server.URL) return client, nil }, servermock.CheckHeader(). WithJSONHeaders(). With("apikey", "secret"), ) } func TestClient_AddRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("success.json"), servermock.CheckRequestJSONBodyFromFixture("add_record-request.json"), ). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 2, } err := client.AddRecord(context.Background(), "example.com", record) require.NoError(t, err) } func TestClient_AddRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest), ). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", TTL: 480, Overwrite: true, } err := client.AddRecord(context.Background(), "example.com", record) require.Error(t, err) require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)") } func TestClient_DeleteRecord(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("success.json"), servermock.CheckRequestJSONBodyFromFixture("delete_record-request.json"), ). Build(t) record := Record{ Type: "TXT", Name: "_acme-challenge", Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", } err := client.DeleteRecord(context.Background(), "example.com", record) require.NoError(t, err) } func TestClient_DeleteRecord_error(t *testing.T) { client := mockBuilder(). Route("POST /", servermock.ResponseFromFixture("error.json"). WithStatusCode(http.StatusBadRequest), ). Build(t) record := Record{ Type: "TXT", Name: "foo", Content: "txtTXTtxt", } err := client.DeleteRecord(context.Background(), "example.com", record) require.Error(t, err) require.EqualError(t, err, "JSON Defined Record Type not Supported (code=6)") } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/add_record-request.json ================================================ { "domain": "example.com", "add": [ { "type": "TXT", "name": "_acme-challenge", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY", "ttl": 2 } ] } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/delete_record-request.json ================================================ { "domain": "example.com", "delete": [ { "type": "TXT", "name": "_acme-challenge", "content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY" } ] } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/error.json ================================================ { "code": 6, "message": "JSON Defined Record Type not Supported" } ================================================ FILE: providers/dns/dnsexit/internal/fixtures/success.json ================================================ { "code": 0, "details": [ "UPDATE Record A example.com. TTL(hh:mm) 08:00 IP 1.1.1.10" ], "message": "Success" } ================================================ FILE: providers/dns/dnsexit/internal/types.go ================================================ package internal import ( "fmt" "strings" ) type Record struct { Type string `json:"type,omitempty"` Name string `json:"name,omitempty"` Content string `json:"content,omitempty"` TTL int `json:"ttl,omitempty"` // NOTE: ttl value is in minutes. Overwrite bool `json:"overwrite,omitempty"` } type APIRequest struct { Domain string `json:"domain,omitempty"` Add []Record `json:"add,omitempty"` Delete []Record `json:"delete,omitempty"` Update []Record `json:"update,omitempty"` } // https://dnsexit.com/dns/dns-api/#server-reply type APIResponse struct { Code int `json:"code,omitempty"` Details []string `json:"details,omitempty"` Message string `json:"message,omitempty"` } func (a APIResponse) Error() string { msg := new(strings.Builder) _, _ = fmt.Fprintf(msg, "%s (code=%d)", a.Message, a.Code) for _, detail := range a.Details { _, _ = fmt.Fprintf(msg, ", %s", detail) } return msg.String() } ================================================ FILE: providers/dns/dnshomede/dnshomede.go ================================================ // Package dnshomede implements a DNS provider for solving the DNS-01 challenge using dnsHome.de. package dnshomede import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/dnshomede/internal" "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug" ) // Environment variables names. const ( envNamespace = "DNSHOMEDE_" EnvCredentials = envNamespace + "CREDENTIALS" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL" ) // Config is used to configure the creation of the DNSProvider. type Config struct { Credentials map[string]string PropagationTimeout time.Duration PollingInterval time.Duration SequenceInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 20*time.Minute), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 2*time.Minute), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client *internal.Client } // NewDNSProvider returns a DNSProvider instance configured for dnsHome.de. // Credentials must be passed in the environment variable: DNSHOMEDE_CREDENTIALS. func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() values, err := env.Get(EnvCredentials) if err != nil { return nil, fmt.Errorf("dnshomede: %w", err) } credentials, err := env.ParsePairs(values[EnvCredentials]) if err != nil { return nil, fmt.Errorf("dnshomede: credentials: %w", err) } config.Credentials = credentials return NewDNSProviderConfig(config) } func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("dnshomede: the configuration of the DNS provider is nil") } if len(config.Credentials) == 0 { return nil, errors.New("dnshomede: missing credentials") } for domain, password := range config.Credentials { if domain == "" { return nil, fmt.Errorf(`dnshomede: missing domain: "%s:%s"`, domain, password) } if password == "" { return nil, fmt.Errorf(`dnshomede: missing password: "%s:%s"`, domain, password) } } client := internal.NewClient(config.Credentials) if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } client.HTTPClient = clientdebug.Wrap(client.HTTPClient) return &DNSProvider{config: config, client: client}, nil } // Present updates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.Add(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dnshomede: %w", err) } return nil } // CleanUp updates the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) err := d.client.Remove(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), info.Value) if err != nil { return fmt.Errorf("dnshomede: %w", err) } return 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 } ================================================ FILE: providers/dns/dnshomede/dnshomede.toml ================================================ Name = "dnsHome.de" Description = '''''' URL = "https://www.dnshome.de" Code = "dnshomede" Since = "v4.10.0" Example = ''' DNSHOMEDE_CREDENTIALS=example.org:password \ lego --dns dnshomede -d '*.example.com' -d example.com run DNSHOMEDE_CREDENTIALS=my.example.org:password1,demo.example.org:password2 \ lego --dns dnshomede -d my.example.org -d demo.example.org ''' [Configuration] [Configuration.Credentials] DNSHOMEDE_CREDENTIALS = "Comma-separated list of domain:password credential pairs" [Configuration.Additional] DNSHOMEDE_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 1200)" DNSHOMEDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 2)" DNSHOMEDE_SEQUENCE_INTERVAL = "Time between sequential requests in seconds (Default: 120)" DNSHOMEDE_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)" ================================================ FILE: providers/dns/dnshomede/dnshomede_test.go ================================================ package dnshomede import ( "testing" "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" ) const envDomain = envNamespace + "DOMAIN" var envTest = tester.NewEnvTest(EnvCredentials).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { desc string envVars map[string]string expected string }{ { desc: "success", envVars: map[string]string{ EnvCredentials: "example.org:123", }, }, { desc: "success multiple domains", envVars: map[string]string{ EnvCredentials: "example.org:123,example.com:456,example.net:789", }, }, { desc: "invalid credentials", envVars: map[string]string{ EnvCredentials: ",", }, expected: `dnshomede: credentials: incorrect pair: `, }, { desc: "missing password", envVars: map[string]string{ EnvCredentials: "example.org:", }, expected: `dnshomede: missing password: "example.org:"`, }, { desc: "missing domain", envVars: map[string]string{ EnvCredentials: ":123", }, expected: `dnshomede: missing domain: ":123"`, }, { desc: "invalid credentials, partial", envVars: map[string]string{ EnvCredentials: "example.org:123,example.net", }, expected: "dnshomede: credentials: incorrect pair: example.net", }, { desc: "missing credentials", envVars: map[string]string{ EnvCredentials: "", }, expected: "dnshomede: some credentials information are missing: DNSHOMEDE_CREDENTIALS", }, } 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 creds map[string]string expected string }{ { desc: "success", creds: map[string]string{"example.org": "123"}, }, { desc: "success multiple domains", creds: map[string]string{ "example.org": "123", "example.com": "456", "example.net": "789", }, }, { desc: "missing credentials", expected: "dnshomede: missing credentials", }, { desc: "missing domain", creds: map[string]string{"": "123"}, expected: `dnshomede: missing domain: ":123"`, }, { desc: "missing password", creds: map[string]string{"example.org": ""}, expected: `dnshomede: missing password: "example.org:"`, }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { config := NewDefaultConfig() config.Credentials = test.creds 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) err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } ================================================ FILE: providers/dns/dnshomede/internal/client.go ================================================ package internal import ( "context" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/go-acme/lego/v4/providers/dns/internal/errutils" ) const ( removeAction = "rm" addAction = "add" ) const successCode = "successfully" const defaultBaseURL = "https://www.dnshome.de/dyndns.php" // Client the dnsHome.de client. type Client struct { baseURL string HTTPClient *http.Client credentials map[string]string credMu sync.Mutex } // NewClient Creates a new Client. func NewClient(credentials map[string]string) *Client { return &Client{ HTTPClient: &http.Client{Timeout: 10 * time.Second}, baseURL: defaultBaseURL, credentials: credentials, } } // Add adds a TXT record. // only one TXT record for ACME is allowed, so it will update the "current" TXT record. func (c *Client) Add(ctx context.Context, hostname, value string) error { domain := strings.TrimPrefix(hostname, "_acme-challenge.") return c.doAction(ctx, domain, addAction, value) } // Remove removes a TXT record. // only one TXT record for ACME is allowed, so it will remove "all" the TXT records. func (c *Client) Remove(ctx context.Context, hostname, value string) error { domain := strings.TrimPrefix(hostname, "_acme-challenge.") return c.doAction(ctx, domain, removeAction, value) } func (c *Client) doAction(ctx context.Context, domain, action, value string) error { endpoint, err := c.createEndpoint(domain, action, value) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), http.NoBody) if err != nil { return fmt.Errorf("unable to create request: %w", err) } resp, err := c.HTTPClient.Do(req) if err != nil { return errutils.NewHTTPDoError(req, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return errutils.NewUnexpectedResponseStatusCodeError(req, resp) } raw, err := io.ReadAll(resp.Body) if err != nil { return errutils.NewReadResponseError(req, resp.StatusCode, err) } output := string(raw) if !strings.HasPrefix(output, successCode) { return errors.New(output) } return nil } func (c *Client) createEndpoint(domain, action, value string) (*url.URL, error) { if len(value) < 12 { return nil, fmt.Errorf("the TXT value must have more than 12 characters: %s", value) } endpoint, err := url.Parse(c.baseURL) if err != nil { return nil, err } c.credMu.Lock() password, ok := c.credentials[domain] c.credMu.Unlock() if !ok { return nil, fmt.Errorf("domain %s not found in credentials, check your credentials map", domain) } endpoint.User = url.UserPassword(domain, password) query := endpoint.Query() query.Set("acme", action) query.Set("txt", value) endpoint.RawQuery = query.Encode() return endpoint, nil } ================================================ FILE: providers/dns/dnshomede/internal/client_test.go ================================================ package internal import ( "fmt" "net/http/httptest" "testing" "github.com/go-acme/lego/v4/platform/tester/servermock" "github.com/stretchr/testify/require" ) func setupClient(credentials map[string]string) func(server *httptest.Server) (*Client, error) { return func(server *httptest.Server) (*Client, error) { client := NewClient(credentials) client.HTTPClient = server.Client() client.baseURL = server.URL return client, nil } } func TestClient_Add(t *testing.T) { txtValue := "123456789012" client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). Route("POST /", servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), servermock.CheckQueryParameter().Strict(). With("acme", addAction).With("txt", txtValue)). Build(t) err := client.Add(t.Context(), "example.org", txtValue) require.NoError(t, err) } func TestClient_Add_error(t *testing.T) { txtValue := "123456789012" client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). Route("POST /", servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), servermock.CheckQueryParameter().Strict(). With("acme", addAction).With("txt", txtValue)). Build(t) err := client.Add(t.Context(), "example.org", txtValue) require.EqualError(t, err, "domain example.org not found in credentials, check your credentials map") } func TestClient_Remove(t *testing.T) { txtValue := "ABCDEFGHIJKL" client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.org": "secret"})). Route("POST /", servermock.RawStringResponse(fmt.Sprintf("%s %s", successCode, txtValue)), servermock.CheckQueryParameter().Strict(). With("acme", removeAction).With("txt", txtValue)). Build(t) err := client.Remove(t.Context(), "example.org", txtValue) require.NoError(t, err) } func TestClient_Remove_error(t *testing.T) { txtValue := "ABCDEFGHIJKL" testCases := []struct { desc string hostname string response string expected string }{ { desc: "response error - txt", hostname: "example.com", response: "error - no valid acme txt record", expected: "error - no valid acme txt record", }, { desc: "response error - acme", hostname: "example.com", response: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", expected: "nochg 1234:1234:1234:1234:1234:1234:1234:1234", }, { desc: "credential error", hostname: "example.org", response: fmt.Sprintf("%s %s", successCode, txtValue), expected: "domain example.org not found in credentials, check your credentials map", }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() client := servermock.NewBuilder[*Client](setupClient(map[string]string{"example.com": "secret"})). Route("POST /", servermock.RawStringResponse(test.response), servermock.CheckQueryParameter().Strict(). With("acme", removeAction).With("txt", txtValue)). Build(t) err := client.Remove(t.Context(), test.hostname, txtValue) require.EqualError(t, err, test.expected) }) } } ================================================ FILE: providers/dns/dnshomede/internal/readme.md ================================================ # dnshome.de API ## Add TXT record ``` https://AuthFailed
AuthFailed