diff --git a/README.md b/README.md index a488a783..7cd9ccce 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 46012f83..a4053851 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 00000000..a7c39965 --- /dev/null +++ b/docs/content/dns/zz_gen_selfhostde.md @@ -0,0 +1,96 @@ +--- +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, 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. + +After that you must edit the TXT record(s) to get the ID(s). + +You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with the following format: + +``` +::,::,:: +``` + +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` + + + + + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 24f43f47..6d0e6aba 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 """ diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index a10f8441..589f904f 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 00000000..7eeca20a --- /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 00000000..8abda8fb --- /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 00000000..d0b01bfe --- /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 + +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/mapping.go b/providers/dns/selfhostde/mapping.go new file mode 100644 index 00000000..0984419e --- /dev/null +++ b/providers/dns/selfhostde/mapping.go @@ -0,0 +1,131 @@ +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(raw string) (map[string]*Seq, error) { + raw = strings.ReplaceAll(raw, " ", "") + + if raw == "" { + return nil, errors.New("empty mapping") + } + + acc := map[string]*Seq{} + + for { + index, err := safeIndex(raw, lineSep) + if err != nil { + return nil, err + } + + if index != -1 { + name, seq, err := parseLine(raw[:index]) + if err != nil { + return nil, err + } + + acc[name] = seq + + // Data for the next iteration. + raw = raw[index+1:] + + continue + } + + name, seq, errP := parseLine(raw) + 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, rawIDs := line[:idx], 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[:idx]) + count++ + + // Data for the next iteration. + rawIDs = rawIDs[idx+1:] + + continue + } + + ids = append(ids, rawIDs) + + 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 00000000..22bf684d --- /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 00000000..32428766 --- /dev/null +++ b/providers/dns/selfhostde/selfhostde.go @@ -0,0 +1,183 @@ +// 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, 4*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + }, + } +} + +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 %q", effectiveDomain) + } + } + + return seq.Next(), nil +} + +// 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 %q", 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) + + recordID, err := d.config.getSeqNext(dns01.UnFqdn(info.EffectiveFQDN)) + if err != nil { + return fmt.Errorf("selfhostde: %w", err) + } + + 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) + + d.recordIDsMu.Lock() + recordID, ok := d.recordIDs[token] + d.recordIDsMu.Unlock() + if !ok { + return fmt.Errorf("selfhostde: unknown record ID for %q", dns01.UnFqdn(info.EffectiveFQDN)) + } + + 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 00000000..72ddad29 --- /dev/null +++ b/providers/dns/selfhostde/selfhostde.toml @@ -0,0 +1,54 @@ +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, 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. + +After that you must edit the TXT record(s) to get the ID(s). + +You then must prepare the `SELFHOSTDE_RECORDS_MAPPING` environment variable with the following format: + +``` +::,::,:: +``` + +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` + +""" + +[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 00000000..1161049b --- /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) +}