From c01911e8d16838a5a627d46d0268032f23b56f3b Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 18 Sep 2024 04:20:25 +0200 Subject: [PATCH 01/10] feat: add DNS provider for SelfHost.de --- providers/dns/dns_providers.go | 3 + providers/dns/selfhostde/internal/client.go | 66 ++++++ .../dns/selfhostde/internal/client_test.go | 65 ++++++ providers/dns/selfhostde/internal/readme.md | 7 + providers/dns/selfhostde/mapping.go | 130 +++++++++++ providers/dns/selfhostde/mapping_test.go | 173 +++++++++++++++ providers/dns/selfhostde/selfhostde.go | 175 +++++++++++++++ providers/dns/selfhostde/selfhostde.toml | 44 ++++ providers/dns/selfhostde/selfhostde_test.go | 208 ++++++++++++++++++ 9 files changed, 871 insertions(+) create mode 100644 providers/dns/selfhostde/internal/client.go create mode 100644 providers/dns/selfhostde/internal/client_test.go create mode 100644 providers/dns/selfhostde/internal/readme.md create mode 100644 providers/dns/selfhostde/mapping.go create mode 100644 providers/dns/selfhostde/mapping_test.go create mode 100644 providers/dns/selfhostde/selfhostde.go create mode 100644 providers/dns/selfhostde/selfhostde.toml create mode 100644 providers/dns/selfhostde/selfhostde_test.go diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index a10f8441c5..589f904fc3 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -116,6 +116,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/scaleway" "github.com/go-acme/lego/v4/providers/dns/selectel" "github.com/go-acme/lego/v4/providers/dns/selectelv2" + "github.com/go-acme/lego/v4/providers/dns/selfhostde" "github.com/go-acme/lego/v4/providers/dns/servercow" "github.com/go-acme/lego/v4/providers/dns/shellrent" "github.com/go-acme/lego/v4/providers/dns/simply" @@ -369,6 +370,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return selectel.NewDNSProvider() case "selectelv2": return selectelv2.NewDNSProvider() + case "selfhostde": + return selfhostde.NewDNSProvider() case "servercow": return servercow.NewDNSProvider() case "shellrent": diff --git a/providers/dns/selfhostde/internal/client.go b/providers/dns/selfhostde/internal/client.go new file mode 100644 index 0000000000..7eeca20a95 --- /dev/null +++ b/providers/dns/selfhostde/internal/client.go @@ -0,0 +1,66 @@ +package internal + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/go-acme/lego/v4/providers/dns/internal/errutils" +) + +const defaultBaseURL = "https://selfhost.de/cgi-bin/api.pl" + +// Client the SelfHost client. +type Client struct { + username string + password string + + baseURL string + HTTPClient *http.Client +} + +// NewClient Creates a new Client. +func NewClient(username, password string) *Client { + return &Client{ + username: username, + password: password, + baseURL: defaultBaseURL, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +// UpdateTXTRecord updates content of an existing TXT record. +func (c *Client) UpdateTXTRecord(ctx context.Context, recordID, content string) error { + endpoint, err := url.Parse(c.baseURL) + if err != nil { + return fmt.Errorf("parse URL: %w", err) + } + + query := endpoint.Query() + query.Set("username", c.username) + query.Set("password", c.password) + query.Set("rid", recordID) + query.Set("content", content) + + endpoint.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return fmt.Errorf("new HTTP 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/100 != 2 { + return errutils.NewUnexpectedResponseStatusCodeError(req, resp) + } + + return nil +} diff --git a/providers/dns/selfhostde/internal/client_test.go b/providers/dns/selfhostde/internal/client_test.go new file mode 100644 index 0000000000..8abda8fb67 --- /dev/null +++ b/providers/dns/selfhostde/internal/client_test.go @@ -0,0 +1,65 @@ +package internal + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T) (*Client, *http.ServeMux) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client := NewClient("user", "secret") + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + client.baseURL = serverURL.String() + + return client, mux +} + +func TestClient_UpdateTXTRecord(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("GET /", func(rw http.ResponseWriter, req *http.Request) { + query := req.URL.Query() + + fields := map[string]string{ + "username": "user", + "password": "secret", + "rid": "123456", + "content": "txt", + } + + for k, v := range fields { + value := query.Get(k) + if value != v { + http.Error(rw, fmt.Sprintf("%s: unexpected value: %s (%s)", k, value, v), http.StatusBadRequest) + return + } + } + }) + + err := client.UpdateTXTRecord(context.Background(), "123456", "txt") + require.NoError(t, err) +} + +func TestClient_UpdateTXTRecord_error(t *testing.T) { + client, mux := setupTest(t) + + mux.HandleFunc("GET /", func(rw http.ResponseWriter, _ *http.Request) { + http.Error(rw, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + }) + + err := client.UpdateTXTRecord(context.Background(), "123456", "txt") + require.Error(t, err) +} diff --git a/providers/dns/selfhostde/internal/readme.md b/providers/dns/selfhostde/internal/readme.md new file mode 100644 index 0000000000..774cd361cd --- /dev/null +++ b/providers/dns/selfhostde/internal/readme.md @@ -0,0 +1,7 @@ +# SelfHost.(de|eu) + +SelfHost doesn't provide an official API documentation and there are no endpoints for create a TXT record or delete a TXT record. + +## More + +This link (https://kirk.selfhost.de/cgi-bin/selfhost?p=document&name=api) content a PDF that doesn't describe the endpoint used by the client. diff --git a/providers/dns/selfhostde/mapping.go b/providers/dns/selfhostde/mapping.go new file mode 100644 index 0000000000..10964cd325 --- /dev/null +++ b/providers/dns/selfhostde/mapping.go @@ -0,0 +1,130 @@ +package selfhostde + +import ( + "errors" + "fmt" + "strings" +) + +const ( + lineSep = "," + recordSep = ":" +) + +type Seq struct { + cursor int + ids []string +} + +func NewSeq(ids ...string) *Seq { + return &Seq{ids: ids} +} + +func (s *Seq) Next() string { + if len(s.ids) == 1 { + return s.ids[0] + } + + v := s.ids[s.cursor] + + if s.cursor < len(s.ids)-1 { + s.cursor++ + } else { + s.cursor = 0 + } + + return v +} + +func parseRecordsMapping(v string) (map[string]*Seq, error) { + v = strings.ReplaceAll(v, " ", "") + + if v == "" { + return nil, errors.New("empty mapping") + } + + acc := map[string]*Seq{} + + for { + index, err := safeIndex(v, lineSep) + if err != nil { + return nil, err + } + + if index != -1 { + name, seq, err := parseLine(v[:index]) + if err != nil { + return nil, err + } + + acc[name] = seq + + v = v[index+1:] + + continue + } + + name, seq, errP := parseLine(v) + if errP != nil { + return nil, errP + } + + acc[name] = seq + + return acc, nil + } +} + +func parseLine(line string) (string, *Seq, error) { + idx, err := safeIndex(line, recordSep) + if err != nil { + return "", nil, err + } + + if idx == -1 { + return "", nil, fmt.Errorf("missing %q: %s", recordSep, line) + } + + name := line[:idx] + rawIDs := line[idx+1:] + + var ids []string + var count int + + for { + idx, err = safeIndex(rawIDs, recordSep) + if err != nil { + return "", nil, err + } + + if count == 2 { + return "", nil, fmt.Errorf("too many record IDs for one domain: %s", line) + } + + if idx == -1 { + ids = append(ids, rawIDs) + break + } + + ids = append(ids, rawIDs[:idx]) + count++ + + // Data for the next iteration. + rawIDs = rawIDs[idx+1:] + } + + return name, NewSeq(ids...), nil +} + +func safeIndex(v, sep string) (int, error) { + index := strings.Index(v, sep) + if index == 0 { + return 0, fmt.Errorf("first char is %q: %s", sep, v) + } + + if index == len(v)-1 { + return 0, fmt.Errorf("last char is %q: %s", sep, v) + } + + return index, nil +} diff --git a/providers/dns/selfhostde/mapping_test.go b/providers/dns/selfhostde/mapping_test.go new file mode 100644 index 0000000000..22bf684d7b --- /dev/null +++ b/providers/dns/selfhostde/mapping_test.go @@ -0,0 +1,173 @@ +package selfhostde + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parseRecordsMapping(t *testing.T) { + testCases := []struct { + desc string + rawData string + expected map[string]*Seq + }{ + { + desc: "one domain, one record id", + rawData: "example.com:123", + expected: map[string]*Seq{ + "example.com": NewSeq("123"), + }, + }, + { + desc: "several domain, one record id", + rawData: "example.com:123, example.org:456,foo.example.com:789", + expected: map[string]*Seq{ + "example.com": NewSeq("123"), + "example.org": NewSeq("456"), + "foo.example.com": NewSeq("789"), + }, + }, + { + desc: "one domain, 2 record ids", + rawData: "example.com:123:456", + expected: map[string]*Seq{ + "example.com": NewSeq("123", "456"), + }, + }, + { + desc: "several domain, 2 record ids", + rawData: "example.com:123:321, example.org:456:654,foo.example.com:789:987", + expected: map[string]*Seq{ + "example.com": NewSeq("123", "321"), + "example.org": NewSeq("456", "654"), + "foo.example.com": NewSeq("789", "987"), + }, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + mapping, err := parseRecordsMapping(test.rawData) + require.NoError(t, err) + + assert.Equal(t, test.expected, mapping) + }) + } +} + +func Test_parseRecordsMapping_error(t *testing.T) { + testCases := []struct { + desc string + rawData string + expected string + }{ + { + desc: "empty", + rawData: "", + expected: "empty mapping", + }, + { + desc: "only spaces", + rawData: " ", + expected: "empty mapping", + }, + { + desc: "one domain, no record id", + rawData: "example.com", + expected: `missing ":": example.com`, + }, + { + desc: "one domain, more than 2 record ids", + rawData: "example.com:123:456:789", + expected: "too many record IDs for one domain: example.com:123:456:789", + }, + { + desc: "several domain, more than 2 record ids", + rawData: "example.com:123, example.org:456:789:147", + expected: "too many record IDs for one domain: example.org:456:789:147", + }, + { + desc: "no ids, ends with 2 dots", + rawData: "example.com:", + expected: `last char is ":": example.com:`, + }, + { + desc: "no ids,starts with 2 dots", + rawData: ":example.com", + expected: `first char is ":": :example.com`, + }, + { + desc: "with ids but ends with 2 dots", + rawData: "example.com:123:", + expected: `last char is ":": 123:`, + }, + { + desc: "only 2 dots", + rawData: ":", + expected: `first char is ":": :`, + }, + { + desc: "only comma", + rawData: ",", + expected: `first char is ",": ,`, + }, + { + desc: "ends with comma", + rawData: "example.com,", + expected: `last char is ",": example.com,`, + }, + { + desc: "combo", + rawData: "::::,::", + expected: `first char is ":": ::::`, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + _, err := parseRecordsMapping(test.rawData) + require.EqualError(t, err, test.expected) + }) + } +} + +func TestSeq_Next(t *testing.T) { + testCases := []struct { + desc string + ids []string + expected []string + }{ + { + desc: "one value", + ids: []string{"a"}, + expected: []string{"a", "a", "a"}, + }, + { + desc: "two values", + ids: []string{"a", "b"}, + expected: []string{"a", "b", "a", "b"}, + }, + { + desc: "three values", + ids: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c", "a", "b", "c", "a"}, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + seq := NewSeq(test.ids...) + for _, s := range test.expected { + assert.Equal(t, s, seq.Next()) + } + }) + } +} diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go new file mode 100644 index 0000000000..c7d534c277 --- /dev/null +++ b/providers/dns/selfhostde/selfhostde.go @@ -0,0 +1,175 @@ +// Package selfhostde implements a DNS provider for solving the DNS-01 challenge using SelfHost.(de|eu). +package selfhostde + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "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/selfhostde/internal" +) + +// Environment variables. +const ( + envNamespace = "SELFHOSTDE_" + + EnvUsername = envNamespace + "USERNAME" + EnvPassword = envNamespace + "PASSWORD" + EnvRecordsMapping = envNamespace + "RECORDS_MAPPING" + + 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 { + Username string + Password string + + RecordsMapping map[string]*Seq + recordsMappingMu sync.Mutex + + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration + 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, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + 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 SelfHost.(de|eu). +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword, EnvRecordsMapping) + if err != nil { + return nil, fmt.Errorf("selfhostde: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + + mapping, err := parseRecordsMapping(values[EnvRecordsMapping]) + if err != nil { + return nil, fmt.Errorf("selfhostde: malformed records mapping: %w", err) + } + + config.RecordsMapping = mapping + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for SelfHost.(de|eu). +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("selfhostde: supplied configuration is nil") + } + + if config.Username == "" || config.Password == "" { + return nil, errors.New("selfhostde: credentials missing") + } + + if len(config.RecordsMapping) == 0 { + return nil, errors.New("selfhostde: missing record mapping") + } + + for domain, seq := range config.RecordsMapping { + if seq == nil || len(seq.ids) == 0 { + return nil, fmt.Errorf("selfhostde: missing record ID for %s", domain) + } + } + + client := internal.NewClient(config.Username, config.Password) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + recordIDs: make(map[string]string), + }, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// 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) + + effectiveDomain := strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge.") + + d.config.recordsMappingMu.Lock() + + seq, ok := d.config.RecordsMapping[effectiveDomain] + if !ok { + return fmt.Errorf("selfhostde: record mapping not found for %s", effectiveDomain) + } + + recordID := seq.Next() + + d.config.recordsMappingMu.Unlock() + + err := d.client.UpdateTXTRecord(context.Background(), recordID, info.Value) + if err != nil { + return fmt.Errorf("selfhostde: update DNS TXT record (id=%s): %w", recordID, err) + } + + d.recordIDsMu.Lock() + d.recordIDs[token] = recordID + d.recordIDsMu.Unlock() + + return nil +} + +// CleanUp removes the TXT record previously created. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + effectiveDomain := strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge.") + + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("selfhostde: unknown record ID for '%s'", effectiveDomain) + } + + err := d.client.UpdateTXTRecord(context.Background(), recordID, "empty") + if err != nil { + return fmt.Errorf("selfhostde: emptied DNS TXT record (id=%s): %w", recordID, err) + } + + return nil +} diff --git a/providers/dns/selfhostde/selfhostde.toml b/providers/dns/selfhostde/selfhostde.toml new file mode 100644 index 0000000000..51066e517c --- /dev/null +++ b/providers/dns/selfhostde/selfhostde.toml @@ -0,0 +1,44 @@ +Name = "SelfHost.(de|eu)" +Description = '''''' +URL = "https://www.selfhost.de" +Code = "selfhostde" +Since = "v4.19.0" + +Example = ''' +SELFHOSTDE_USERNAME=xxx \ +SELFHOSTDE_PASSWORD=yyy \ +SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \ +lego --email you@example.com --dns selfhostde --domains my.example.org run +''' + +Additional = """ +SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. + +So,bBefore using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), +you should create: +- one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. +- two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. + +After that you must edit the TXT record(s) to get the ID(s). + +You should recreate a mapping to fill the `SELFHOSTDE_RECORDS_MAPPING` environement variable as following: + +``` +::,::,:: +``` + +Each group of domain + record id(s) is separated with a comma `,`. + +Each record id is separated with 2 dots `:`. +""" + +[Configuration] + [Configuration.Credentials] + SELFHOSTDE_USERNAME = "Username" + SELFHOSTDE_PASSWORD = "Password" + SELFHOSTDE_RECORDS_MAPPING = "Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147)" + [Configuration.Additional] + SELFHOSTDE_POLLING_INTERVAL = "Time between DNS propagation check" + SELFHOSTDE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + SELFHOSTDE_TTL = "The TTL of the TXT record used for the DNS challenge" + SELFHOSTDE_HTTP_TIMEOUT = "API request timeout" diff --git a/providers/dns/selfhostde/selfhostde_test.go b/providers/dns/selfhostde/selfhostde_test.go new file mode 100644 index 0000000000..4e0be50f64 --- /dev/null +++ b/providers/dns/selfhostde/selfhostde_test.go @@ -0,0 +1,208 @@ +package selfhostde + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvRecordsMapping). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvRecordsMapping: "example.com:123", + }, + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvPassword: "secret", + EnvRecordsMapping: "example.com:123", + }, + expected: "selfhostde: some credentials information are missing: SELFHOSTDE_USERNAME", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvUsername: "user", + EnvRecordsMapping: "example.com:123", + }, + expected: "selfhostde: some credentials information are missing: SELFHOSTDE_PASSWORD", + }, + { + desc: "missing records mapping", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + }, + expected: "selfhostde: some credentials information are missing: SELFHOSTDE_RECORDS_MAPPING", + }, + { + desc: "invalid records mapping", + envVars: map[string]string{ + EnvUsername: "user", + EnvPassword: "secret", + EnvRecordsMapping: "example.com", + }, + expected: `selfhostde: malformed records mapping: missing ":": example.com`, + }, + { + desc: "missing information", + envVars: map[string]string{}, + expected: "selfhostde: some credentials information are missing: SELFHOSTDE_USERNAME,SELFHOSTDE_PASSWORD,SELFHOSTDE_RECORDS_MAPPING", + }, + } + + 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) + assert.NotNil(t, p.config) + assert.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + username string + password string + recordMapping map[string]*Seq + expected string + }{ + { + desc: "success", + username: "user", + password: "secret", + recordMapping: map[string]*Seq{ + "example.com": NewSeq("123"), + }, + }, + { + desc: "missing username", + password: "secret", + recordMapping: map[string]*Seq{ + "example.com": NewSeq("123"), + }, + expected: "selfhostde: credentials missing", + }, + { + desc: "missing password", + username: "user", + recordMapping: map[string]*Seq{ + "example.com": NewSeq("123"), + }, + expected: "selfhostde: credentials missing", + }, + { + desc: "missing sequence", + username: "user", + password: "secret", + recordMapping: map[string]*Seq{ + "example.com": nil, + }, + expected: "selfhostde: missing record ID for example.com", + }, + { + desc: "empty sequence", + username: "user", + password: "secret", + recordMapping: map[string]*Seq{ + "example.com": NewSeq(), + }, + expected: "selfhostde: missing record ID for example.com", + }, + { + desc: "missing records mapping", + username: "user", + password: "secret", + expected: "selfhostde: missing record mapping", + }, + { + desc: "empty records mapping", + username: "user", + password: "secret", + recordMapping: map[string]*Seq{}, + expected: "selfhostde: missing record mapping", + }, + { + desc: "missing information", + expected: "selfhostde: credentials missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.Username = test.username + config.Password = test.password + config.RecordsMapping = test.recordMapping + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + assert.NotNil(t, p.config) + assert.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) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} From c6ae3ec17ee82eaaf1891903557e6fbf45992626 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 18 Sep 2024 05:50:33 +0200 Subject: [PATCH 02/10] chore: generate --- README.md | 15 ++--- cmd/zz_gen_cmd_dnshelp.go | 23 +++++++ docs/content/dns/zz_gen_selfhostde.md | 86 +++++++++++++++++++++++++++ docs/data/zz_cli_help.toml | 2 +- 4 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 docs/content/dns/zz_gen_selfhostde.md diff --git a/README.md b/README.md index a488a7836a..7cd9cccec6 100644 --- a/README.md +++ b/README.md @@ -80,13 +80,14 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [plesk.com](https://go-acme.github.io/lego/dns/plesk/) | | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RcodeZero](https://go-acme.github.io/lego/dns/rcodezero/) | | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | -| [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel v2](https://go-acme.github.io/lego/dns/selectelv2/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | -| [Shellrent](https://go-acme.github.io/lego/dns/shellrent/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | -| [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) | -| [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | -| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | -| [Webnames](https://go-acme.github.io/lego/dns/webnames/) | [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex 360](https://go-acme.github.io/lego/dns/yandex360/) | -| [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | +| [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel v2](https://go-acme.github.io/lego/dns/selectelv2/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [SelfHost.(de/eu)](https://go-acme.github.io/lego/dns/selfhostde/) | +| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Shellrent](https://go-acme.github.io/lego/dns/shellrent/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | +| [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | +| [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) | [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | +| [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Webnames](https://go-acme.github.io/lego/dns/webnames/) | [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | +| [Yandex 360](https://go-acme.github.io/lego/dns/yandex360/) | [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | +| [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 46012f8323..a4053851e5 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -125,6 +125,7 @@ func allDNSCodes() string { "scaleway", "selectel", "selectelv2", + "selfhostde", "servercow", "shellrent", "simply", @@ -2553,6 +2554,28 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/selectelv2`) + case "selfhostde": + // generated from: providers/dns/selfhostde/selfhostde.toml + ew.writeln(`Configuration for SelfHost.(de|eu).`) + ew.writeln(`Code: 'selfhostde'`) + ew.writeln(`Since: 'v4.19.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "SELFHOSTDE_PASSWORD": Password`) + ew.writeln(` - "SELFHOSTDE_RECORDS_MAPPING": Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147)`) + ew.writeln(` - "SELFHOSTDE_USERNAME": Username`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "SELFHOSTDE_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "SELFHOSTDE_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "SELFHOSTDE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "SELFHOSTDE_TTL": The TTL of the TXT record used for the DNS challenge`) + + ew.writeln() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/selfhostde`) + case "servercow": // generated from: providers/dns/servercow/servercow.toml ew.writeln(`Configuration for Servercow.`) diff --git a/docs/content/dns/zz_gen_selfhostde.md b/docs/content/dns/zz_gen_selfhostde.md new file mode 100644 index 0000000000..ad18cd92dc --- /dev/null +++ b/docs/content/dns/zz_gen_selfhostde.md @@ -0,0 +1,86 @@ +--- +title: "SelfHost.(de|eu)" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: selfhostde +dnsprovider: + since: "v4.19.0" + code: "selfhostde" + url: "https://www.selfhost.de" +--- + + + + + + +Configuration for [SelfHost.(de|eu)](https://www.selfhost.de). + + + + +- Code: `selfhostde` +- Since: v4.19.0 + + +Here is an example bash command using the SelfHost.(de|eu) provider: + +```bash +SELFHOSTDE_USERNAME=xxx \ +SELFHOSTDE_PASSWORD=yyy \ +SELFHOSTDE_RECORDS_MAPPING=my.example.com:123 \ +lego --email you@example.com --dns selfhostde --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `SELFHOSTDE_PASSWORD` | Password | +| `SELFHOSTDE_RECORDS_MAPPING` | Record IDs mapping with domains (ex: example.com:123:456,example.org:789,foo.example.com:147) | +| `SELFHOSTDE_USERNAME` | Username | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `SELFHOSTDE_HTTP_TIMEOUT` | API request timeout | +| `SELFHOSTDE_POLLING_INTERVAL` | Time between DNS propagation check | +| `SELFHOSTDE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `SELFHOSTDE_TTL` | The TTL of the TXT record used for the DNS challenge | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here]({{% ref "dns#configuration-and-credentials" %}}). + +SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. + +So,bBefore using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), +you should create: +- one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. +- two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. + +After that you must edit the TXT record(s) to get the ID(s). + +You should recreate a mapping to fill the `SELFHOSTDE_RECORDS_MAPPING` environement variable as following: + +``` +::,::,:: +``` + +Each group of domain + record id(s) is separated with a comma `,`. + +Each record id is separated with 2 dots `:`. + + + + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 24f43f47a0..6d0e6aba7b 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -139,7 +139,7 @@ To display the documentation for a specific DNS provider, run: $ lego dnshelp -c code Supported DNS providers: - acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manual, metaname, mijnhost, mittwald, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, servercow, shellrent, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi + acme-dns, alidns, allinkl, arvancloud, auroradns, autodns, azure, azuredns, bindman, bluecat, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, constellix, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dynu, easydns, edgedns, efficientip, epik, exec, exoscale, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manual, metaname, mijnhost, mittwald, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ From 24ba753bda0554f82e387fb031e5f5c31947605c Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 18 Sep 2024 16:41:48 +0200 Subject: [PATCH 03/10] feat: fallback on fqdn --- providers/dns/selfhostde/selfhostde.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go index c7d534c277..d403cfa544 100644 --- a/providers/dns/selfhostde/selfhostde.go +++ b/providers/dns/selfhostde/selfhostde.go @@ -128,13 +128,17 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - effectiveDomain := strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge.") + effectiveDomain := strings.TrimPrefix(dns01.UnFqdn(info.EffectiveFQDN), "_acme-challenge.") d.config.recordsMappingMu.Lock() seq, ok := d.config.RecordsMapping[effectiveDomain] if !ok { - return fmt.Errorf("selfhostde: record mapping not found for %s", effectiveDomain) + // fallback + seq, ok = d.config.RecordsMapping[dns01.UnFqdn(info.EffectiveFQDN)] + if !ok { + return fmt.Errorf("selfhostde: record mapping not found for %s", effectiveDomain) + } } recordID := seq.Next() From bb6b378a0433ae4ad45003e582c9f67df3b1c132 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 18 Sep 2024 21:56:31 +0200 Subject: [PATCH 04/10] feat: change default propagation timeout --- providers/dns/selfhostde/selfhostde.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go index d403cfa544..c6545168fa 100644 --- a/providers/dns/selfhostde/selfhostde.go +++ b/providers/dns/selfhostde/selfhostde.go @@ -47,8 +47,8 @@ type Config struct { func NewDefaultConfig() *Config { return &Config{ TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 4*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, From 565838db266d4025b11a398dc4438968493aff81 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 18 Sep 2024 21:57:03 +0200 Subject: [PATCH 05/10] fix: unlock when error --- providers/dns/selfhostde/selfhostde.go | 1 + 1 file changed, 1 insertion(+) diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go index c6545168fa..dc6fb2aa1d 100644 --- a/providers/dns/selfhostde/selfhostde.go +++ b/providers/dns/selfhostde/selfhostde.go @@ -137,6 +137,7 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { // fallback seq, ok = d.config.RecordsMapping[dns01.UnFqdn(info.EffectiveFQDN)] if !ok { + d.config.recordsMappingMu.Unlock() return fmt.Errorf("selfhostde: record mapping not found for %s", effectiveDomain) } } From 7992d762974f43bb86d351e0a726e4bbe431cb4c Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 18 Sep 2024 22:05:42 +0200 Subject: [PATCH 06/10] refactor: get sequence next --- providers/dns/selfhostde/selfhostde.go | 39 +++++++++++++++----------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go index dc6fb2aa1d..a46e50e201 100644 --- a/providers/dns/selfhostde/selfhostde.go +++ b/providers/dns/selfhostde/selfhostde.go @@ -55,6 +55,24 @@ func NewDefaultConfig() *Config { } } +func (c *Config) getSeqNext(domain string) (string, error) { + effectiveDomain := strings.TrimPrefix(domain, "_acme-challenge.") + + c.recordsMappingMu.Lock() + defer c.recordsMappingMu.Unlock() + + seq, ok := c.RecordsMapping[effectiveDomain] + if !ok { + // fallback + seq, ok = c.RecordsMapping[domain] + if !ok { + return "", fmt.Errorf("record mapping not found for %s", effectiveDomain) + } + } + + return seq.Next(), nil +} + // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config @@ -128,25 +146,12 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { func (d *DNSProvider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - effectiveDomain := strings.TrimPrefix(dns01.UnFqdn(info.EffectiveFQDN), "_acme-challenge.") - - d.config.recordsMappingMu.Lock() - - seq, ok := d.config.RecordsMapping[effectiveDomain] - if !ok { - // fallback - seq, ok = d.config.RecordsMapping[dns01.UnFqdn(info.EffectiveFQDN)] - if !ok { - d.config.recordsMappingMu.Unlock() - return fmt.Errorf("selfhostde: record mapping not found for %s", effectiveDomain) - } + recordID, err := d.config.getSeqNext(dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("selfhostde: %w", err) } - recordID := seq.Next() - - d.config.recordsMappingMu.Unlock() - - err := d.client.UpdateTXTRecord(context.Background(), recordID, info.Value) + err = d.client.UpdateTXTRecord(context.Background(), recordID, info.Value) if err != nil { return fmt.Errorf("selfhostde: update DNS TXT record (id=%s): %w", recordID, err) } From fb49ebfc50a2653c5cf0178e69d17a71647078b3 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 19 Sep 2024 13:51:59 +0200 Subject: [PATCH 07/10] chore: cosmetic changes --- docs/content/dns/zz_gen_selfhostde.md | 2 +- providers/dns/selfhostde/mapping.go | 37 +++++++++++---------- providers/dns/selfhostde/selfhostde.go | 8 ++--- providers/dns/selfhostde/selfhostde.toml | 2 +- providers/dns/selfhostde/selfhostde_test.go | 4 +-- 5 files changed, 26 insertions(+), 27 deletions(-) diff --git a/docs/content/dns/zz_gen_selfhostde.md b/docs/content/dns/zz_gen_selfhostde.md index ad18cd92dc..62c5838db7 100644 --- a/docs/content/dns/zz_gen_selfhostde.md +++ b/docs/content/dns/zz_gen_selfhostde.md @@ -61,7 +61,7 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. -So,bBefore using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), +So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), you should create: - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. diff --git a/providers/dns/selfhostde/mapping.go b/providers/dns/selfhostde/mapping.go index 10964cd325..0984419ef9 100644 --- a/providers/dns/selfhostde/mapping.go +++ b/providers/dns/selfhostde/mapping.go @@ -36,35 +36,36 @@ func (s *Seq) Next() string { return v } -func parseRecordsMapping(v string) (map[string]*Seq, error) { - v = strings.ReplaceAll(v, " ", "") +func parseRecordsMapping(raw string) (map[string]*Seq, error) { + raw = strings.ReplaceAll(raw, " ", "") - if v == "" { + if raw == "" { return nil, errors.New("empty mapping") } acc := map[string]*Seq{} for { - index, err := safeIndex(v, lineSep) + index, err := safeIndex(raw, lineSep) if err != nil { return nil, err } if index != -1 { - name, seq, err := parseLine(v[:index]) + name, seq, err := parseLine(raw[:index]) if err != nil { return nil, err } acc[name] = seq - v = v[index+1:] + // Data for the next iteration. + raw = raw[index+1:] continue } - name, seq, errP := parseLine(v) + name, seq, errP := parseLine(raw) if errP != nil { return nil, errP } @@ -85,8 +86,7 @@ func parseLine(line string) (string, *Seq, error) { return "", nil, fmt.Errorf("missing %q: %s", recordSep, line) } - name := line[:idx] - rawIDs := line[idx+1:] + name, rawIDs := line[:idx], line[idx+1:] var ids []string var count int @@ -101,19 +101,20 @@ func parseLine(line string) (string, *Seq, error) { return "", nil, fmt.Errorf("too many record IDs for one domain: %s", line) } - if idx == -1 { - ids = append(ids, rawIDs) - break + if idx != -1 { + ids = append(ids, rawIDs[:idx]) + count++ + + // Data for the next iteration. + rawIDs = rawIDs[idx+1:] + + continue } - ids = append(ids, rawIDs[:idx]) - count++ + ids = append(ids, rawIDs) - // Data for the next iteration. - rawIDs = rawIDs[idx+1:] + return name, NewSeq(ids...), nil } - - return name, NewSeq(ids...), nil } func safeIndex(v, sep string) (int, error) { diff --git a/providers/dns/selfhostde/selfhostde.go b/providers/dns/selfhostde/selfhostde.go index a46e50e201..3242876653 100644 --- a/providers/dns/selfhostde/selfhostde.go +++ b/providers/dns/selfhostde/selfhostde.go @@ -66,7 +66,7 @@ func (c *Config) getSeqNext(domain string) (string, error) { // fallback seq, ok = c.RecordsMapping[domain] if !ok { - return "", fmt.Errorf("record mapping not found for %s", effectiveDomain) + return "", fmt.Errorf("record mapping not found for %q", effectiveDomain) } } @@ -119,7 +119,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { for domain, seq := range config.RecordsMapping { if seq == nil || len(seq.ids) == 0 { - return nil, fmt.Errorf("selfhostde: missing record ID for %s", domain) + return nil, fmt.Errorf("selfhostde: missing record ID for %q", domain) } } @@ -167,13 +167,11 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error { func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) - effectiveDomain := strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge.") - d.recordIDsMu.Lock() recordID, ok := d.recordIDs[token] d.recordIDsMu.Unlock() if !ok { - return fmt.Errorf("selfhostde: unknown record ID for '%s'", effectiveDomain) + return fmt.Errorf("selfhostde: unknown record ID for %q", dns01.UnFqdn(info.EffectiveFQDN)) } err := d.client.UpdateTXTRecord(context.Background(), recordID, "empty") diff --git a/providers/dns/selfhostde/selfhostde.toml b/providers/dns/selfhostde/selfhostde.toml index 51066e517c..b9567dc206 100644 --- a/providers/dns/selfhostde/selfhostde.toml +++ b/providers/dns/selfhostde/selfhostde.toml @@ -14,7 +14,7 @@ lego --email you@example.com --dns selfhostde --domains my.example.org run Additional = """ SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. -So,bBefore using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), +So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), you should create: - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. diff --git a/providers/dns/selfhostde/selfhostde_test.go b/providers/dns/selfhostde/selfhostde_test.go index 4e0be50f64..1161049b07 100644 --- a/providers/dns/selfhostde/selfhostde_test.go +++ b/providers/dns/selfhostde/selfhostde_test.go @@ -128,7 +128,7 @@ func TestNewDNSProviderConfig(t *testing.T) { recordMapping: map[string]*Seq{ "example.com": nil, }, - expected: "selfhostde: missing record ID for example.com", + expected: `selfhostde: missing record ID for "example.com"`, }, { desc: "empty sequence", @@ -137,7 +137,7 @@ func TestNewDNSProviderConfig(t *testing.T) { recordMapping: map[string]*Seq{ "example.com": NewSeq(), }, - expected: "selfhostde: missing record ID for example.com", + expected: `selfhostde: missing record ID for "example.com"`, }, { desc: "missing records mapping", From 8bc112f8a651acef9a541f8c76e8a451392c8291 Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Fri, 20 Sep 2024 00:23:46 +0200 Subject: [PATCH 08/10] Apply suggestions from code review Co-authored-by: Dominik Menke --- providers/dns/selfhostde/internal/readme.md | 2 +- providers/dns/selfhostde/selfhostde.toml | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/providers/dns/selfhostde/internal/readme.md b/providers/dns/selfhostde/internal/readme.md index 774cd361cd..d0b01bfe44 100644 --- a/providers/dns/selfhostde/internal/readme.md +++ b/providers/dns/selfhostde/internal/readme.md @@ -4,4 +4,4 @@ SelfHost doesn't provide an official API documentation and there are no endpoint ## More -This link (https://kirk.selfhost.de/cgi-bin/selfhost?p=document&name=api) content a PDF that doesn't describe the endpoint used by the client. +The documentation found at https://kirk.selfhost.de/cgi-bin/selfhost?p=document&name=api (PDF) describes the DynDNS/ddns API endpoint and is not used by our client. diff --git a/providers/dns/selfhostde/selfhostde.toml b/providers/dns/selfhostde/selfhostde.toml index b9567dc206..1f4c59fe22 100644 --- a/providers/dns/selfhostde/selfhostde.toml +++ b/providers/dns/selfhostde/selfhostde.toml @@ -15,21 +15,27 @@ Additional = """ SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), -you should create: +you must create: - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. After that you must edit the TXT record(s) to get the ID(s). -You should recreate a mapping to fill the `SELFHOSTDE_RECORDS_MAPPING` environement variable as following: +You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with the following format: ``` ::,::,:: ``` -Each group of domain + record id(s) is separated with a comma `,`. +where each group of domain + record ID(s) is separated with a comma (`,`), and the domain and record ID(s) are separated with a colon (`:`). + +For example, if you want to create or renew a certificate for `my.example.org`, `*.my.example.org`, and `other.example.org`, you would need: + +- two separate records for `_acme-challenge.my.example.org`, and +- another separate record for `_acme-challenge.other.example.org`. + +The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my.example.com:123:456,other.example.com:789` -Each record id is separated with 2 dots `:`. """ [Configuration] From d3867723236176647dabf976087e987e97658e94 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Fri, 20 Sep 2024 00:25:53 +0200 Subject: [PATCH 09/10] doc: minor changes --- providers/dns/selfhostde/selfhostde.toml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/providers/dns/selfhostde/selfhostde.toml b/providers/dns/selfhostde/selfhostde.toml index 1f4c59fe22..72ddad2975 100644 --- a/providers/dns/selfhostde/selfhostde.toml +++ b/providers/dns/selfhostde/selfhostde.toml @@ -12,10 +12,12 @@ lego --email you@example.com --dns selfhostde --domains my.example.org run ''' Additional = """ -SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. +SelfHost.de doesn't have an API to create or delete TXT records, +there is only an "unofficial" and undocumented endpoint to update an existing TXT record. So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), you must create: + - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. @@ -27,12 +29,14 @@ You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with ::,::,:: ``` -where each group of domain + record ID(s) is separated with a comma (`,`), and the domain and record ID(s) are separated with a colon (`:`). +where each group of domain + record ID(s) is separated with a comma (`,`), +and the domain and record ID(s) are separated with a colon (`:`). -For example, if you want to create or renew a certificate for `my.example.org`, `*.my.example.org`, and `other.example.org`, you would need: +For example, if you want to create or renew a certificate for `my.example.org`, `*.my.example.org`, and `other.example.org`, +you would need: -- two separate records for `_acme-challenge.my.example.org`, and -- another separate record for `_acme-challenge.other.example.org`. +- two separate records for `_acme-challenge.my.example.org` +- and another separate record for `_acme-challenge.other.example.org` The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my.example.com:123:456,other.example.com:789` From 1fba029da0ba66c16a2fd90e45ebbc9331c9d2c3 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Fri, 20 Sep 2024 00:26:13 +0200 Subject: [PATCH 10/10] chore: generate --- docs/content/dns/zz_gen_selfhostde.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/content/dns/zz_gen_selfhostde.md b/docs/content/dns/zz_gen_selfhostde.md index 62c5838db7..a7c3996519 100644 --- a/docs/content/dns/zz_gen_selfhostde.md +++ b/docs/content/dns/zz_gen_selfhostde.md @@ -59,24 +59,34 @@ More information [here]({{% ref "dns#configuration-and-credentials" %}}). The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{% ref "dns#configuration-and-credentials" %}}). -SelfHost.de doesn't have an API to create or delete TXT records, there is only an "unofficial" and undocumented endpoint to update an existing TXT record. +SelfHost.de doesn't have an API to create or delete TXT records, +there is only an "unofficial" and undocumented endpoint to update an existing TXT record. So, before using lego to request a certificate for a given domain or wildcard (such as `my.example.org` or `*.my.example.org`), -you should create: +you must create: + - one TXT record named `_acme-challenge.my.example.org` if you are **not** using wildcard for this domain. - two TXT records named `_acme-challenge.my.example.org` if you are using wildcard for this domain. After that you must edit the TXT record(s) to get the ID(s). -You should recreate a mapping to fill the `SELFHOSTDE_RECORDS_MAPPING` environement variable as following: +You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with the following format: ``` ::,::,:: ``` -Each group of domain + record id(s) is separated with a comma `,`. +where each group of domain + record ID(s) is separated with a comma (`,`), +and the domain and record ID(s) are separated with a colon (`:`). + +For example, if you want to create or renew a certificate for `my.example.org`, `*.my.example.org`, and `other.example.org`, +you would need: + +- two separate records for `_acme-challenge.my.example.org` +- and another separate record for `_acme-challenge.other.example.org` + +The resulting environment variable would then be: `SELFHOSTDE_RECORDS_MAPPING=my.example.com:123:456,other.example.com:789` -Each record id is separated with 2 dots `:`.