diff --git a/README.md b/README.md
index 9c2e81cfc..51944b9d8 100644
--- a/README.md
+++ b/README.md
@@ -70,193 +70,193 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
| Azure DNS |
Baidu Cloud |
+ Beget.com |
Binary Lane |
- Bindman |
+ | Bindman |
Bluecat |
BookMyName |
Brandit (deprecated) |
- Bunny |
+ | Bunny |
Checkdomain |
Civo |
Cloud.ru |
- CloudDNS |
+ | CloudDNS |
Cloudflare |
ClouDNS |
CloudXNS (Deprecated) |
- ConoHa v2 |
+ | ConoHa v2 |
ConoHa v3 |
Constellix |
Core-Networks |
- CPanel/WHM |
+ | CPanel/WHM |
Derak Cloud |
deSEC.io |
Designate DNSaaS for Openstack |
- Digital Ocean |
+ | Digital Ocean |
DirectAdmin |
DNS Made Easy |
dnsHome.de |
- DNSimple |
+ | DNSimple |
DNSPod (deprecated) |
Domain Offensive (do.de) |
Domeneshop |
- DreamHost |
+ | DreamHost |
Duck DNS |
Dyn |
DynDnsFree.de |
- Dynu |
+ | Dynu |
EasyDNS |
Efficient IP |
Epik |
- Exoscale |
+ | Exoscale |
External program |
F5 XC |
freemyip.com |
- G-Core |
+ | G-Core |
Gandi |
Gandi Live DNS (v5) |
Glesys |
- Go Daddy |
+ | Go Daddy |
Google Cloud |
Google Domains |
Hetzner |
- Hosting.de |
+ | Hosting.de |
Hostinger |
Hosttech |
HTTP request |
- http.net |
+ | http.net |
Huawei Cloud |
Hurricane Electric DNS |
HyperOne |
- IBM Cloud (SoftLayer) |
+ | IBM Cloud (SoftLayer) |
IIJ DNS Platform Service |
Infoblox |
Infomaniak |
- Internet Initiative Japan |
+ | Internet Initiative Japan |
Internet.bs |
INWX |
Ionos |
- IPv64 |
+ | IPv64 |
iwantmyname |
Joker |
Joohoi's ACME-DNS |
- KeyHelp |
+ | KeyHelp |
Liara |
Lima-City |
Linode (v4) |
- Liquid Web |
+ | Liquid Web |
Loopia |
LuaDNS |
Mail-in-a-Box |
- ManageEngine CloudDNS |
+ | ManageEngine CloudDNS |
Manual |
Metaname |
Metaregistrar |
- mijn.host |
+ | mijn.host |
Mittwald |
myaddr.{tools,dev,io} |
MyDNS.jp |
- MythicBeasts |
+ | MythicBeasts |
Name.com |
Namecheap |
Namesilo |
- NearlyFreeSpeech.NET |
+ | NearlyFreeSpeech.NET |
Netcup |
Netlify |
Nicmanager |
- NIFCloud |
+ | NIFCloud |
Njalla |
Nodion |
NS1 |
- Open Telekom Cloud |
+ | Open Telekom Cloud |
Oracle Cloud |
OVH |
plesk.com |
- Porkbun |
+ | Porkbun |
PowerDNS |
Rackspace |
Rain Yun/雨云 |
- RcodeZero |
+ | RcodeZero |
reg.ru |
Regfish |
RFC2136 |
- RimuHosting |
+ | RimuHosting |
RU CENTER |
Sakura Cloud |
Scaleway |
- Selectel |
+ | Selectel |
Selectel v2 |
SelfHost.(de|eu) |
Servercow |
- Shellrent |
+ | Shellrent |
Simply.com |
Sonic |
Spaceship |
- Stackpath |
+ | Stackpath |
Technitium |
Tencent Cloud DNS |
Tencent EdgeOne |
- Timeweb Cloud |
+ | Timeweb Cloud |
TransIP |
UKFast SafeDNS |
Ultradns |
- Variomedia |
+ | Variomedia |
VegaDNS |
Vercel |
Versio.[nl|eu|uk] |
- VinylDNS |
+ | VinylDNS |
VK Cloud |
Volcano Engine/火山引擎 |
Vscale |
- Vultr |
+ | Vultr |
Webnames |
Websupport |
WEDOS |
- West.cn/西部数码 |
+ | West.cn/西部数码 |
Yandex 360 |
Yandex Cloud |
Yandex PDD |
- Zone.ee |
+ | Zone.ee |
ZoneEdit |
Zonomi |
|
- |
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index bffb0696f..f3f603cf1 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -25,6 +25,7 @@ func allDNSCodes() string {
"azure",
"azuredns",
"baiducloud",
+ "beget",
"binarylane",
"bindman",
"bluecat",
@@ -451,6 +452,27 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/baiducloud`)
+ case "beget":
+ // generated from: providers/dns/beget/beget.toml
+ ew.writeln(`Configuration for Beget.com.`)
+ ew.writeln(`Code: 'beget'`)
+ ew.writeln(`Since: 'v4.27.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "BEGET_PASSWORD": API password`)
+ ew.writeln(` - "BEGET_USERNAME": API username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "BEGET_HTTP_TIMEOUT": API request timeout in seconds (Default: 30)`)
+ ew.writeln(` - "BEGET_POLLING_INTERVAL": Time between DNS propagation check in seconds (Default: 30)`)
+ ew.writeln(` - "BEGET_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation in seconds (Default: 300)`)
+ ew.writeln(` - "BEGET_TTL": The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)`)
+
+ ew.writeln()
+ ew.writeln(`More information: https://go-acme.github.io/lego/dns/beget`)
+
case "binarylane":
// generated from: providers/dns/binarylane/binarylane.toml
ew.writeln(`Configuration for Binary Lane.`)
diff --git a/docs/content/dns/zz_gen_beget.md b/docs/content/dns/zz_gen_beget.md
new file mode 100644
index 000000000..ae1d16a7c
--- /dev/null
+++ b/docs/content/dns/zz_gen_beget.md
@@ -0,0 +1,69 @@
+---
+title: "Beget.com"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: beget
+dnsprovider:
+ since: "v4.27.0"
+ code: "beget"
+ url: "https://beget.com/"
+---
+
+
+
+
+
+
+Configuration for [Beget.com](https://beget.com/).
+
+
+
+
+- Code: `beget`
+- Since: v4.27.0
+
+
+Here is an example bash command using the Beget.com provider:
+
+```bash
+BEGET_USERNAME=xxxxxx \
+BEGET_PASSWORD=yyyyyy \
+lego --email you@example.com --dns beget -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `BEGET_PASSWORD` | API password |
+| `BEGET_USERNAME` | API 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 |
+|--------------------------------|-------------|
+| `BEGET_HTTP_TIMEOUT` | API request timeout in seconds (Default: 30) |
+| `BEGET_POLLING_INTERVAL` | Time between DNS propagation check in seconds (Default: 30) |
+| `BEGET_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation in seconds (Default: 300) |
+| `BEGET_TTL` | The TTL of the TXT record used for the DNS challenge in seconds (Default: 120) |
+
+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" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://beget.com/ru/kb/api/funkczii-upravleniya-dns)
+
+
+
+
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index c54206ffb..edcf0b7a7 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -152,7 +152,7 @@ To display the documentation for a specific DNS provider, run:
$ lego dnshelp -c code
Supported DNS providers:
- acme-dns, active24, alidns, allinkl, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hostinger, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
+ acme-dns, active24, alidns, allinkl, arvancloud, auroradns, autodns, axelname, azion, azure, azuredns, baiducloud, beget, binarylane, bindman, bluecat, bookmyname, brandit, bunny, checkdomain, civo, clouddns, cloudflare, cloudns, cloudru, cloudxns, conoha, conohav3, constellix, corenetworks, cpanel, derak, desec, designate, digitalocean, directadmin, dnshomede, dnsimple, dnsmadeeasy, dnspod, dode, domeneshop, dreamhost, duckdns, dyn, dyndnsfree, dynu, easydns, edgedns, edgeone, efficientip, epik, exec, exoscale, f5xc, freemyip, gandi, gandiv5, gcloud, gcore, glesys, godaddy, googledomains, hetzner, hostingde, hostinger, hosttech, httpnet, httpreq, huaweicloud, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, keyhelp, liara, lightsail, limacity, linode, liquidweb, loopia, luadns, mailinabox, manageengine, manual, metaname, metaregistrar, mijnhost, mittwald, myaddr, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, spaceship, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneedit, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""
diff --git a/providers/dns/beget/beget.go b/providers/dns/beget/beget.go
new file mode 100644
index 000000000..d5354ac86
--- /dev/null
+++ b/providers/dns/beget/beget.go
@@ -0,0 +1,156 @@
+// Package beget implements a DNS provider for solving the DNS-01 challenge using beget.com DNS.
+package beget
+
+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/beget/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "BEGET_"
+
+ EnvUsername = envNamespace + "USERNAME"
+ EnvPassword = envNamespace + "PASSWORD"
+
+ 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 {
+ Username string
+ Password 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, 300),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 5*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*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 beget.com.
+// Credentials must be passed in the environment variables:
+// BEGET_USERNAME and BEGET_PASSWORD.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("beget: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for beget.com.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("beget: the configuration of the DNS provider is nil")
+ }
+
+ if config.Username == "" || config.Password == "" {
+ return nil, errors.New("beget: incomplete credentials, missing username and/or password")
+ }
+
+ client := internal.NewClient(config.Username, config.Password)
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ 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 {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ records, err := d.client.GetTXTRecords(context.Background(), dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("beget: get TXT records: %w", err)
+ }
+
+ records = append(records, internal.Record{
+ Value: info.Value,
+ Data: "", // NOTE: there are 2 fields in the API for the same thing.
+ Priority: 10,
+ TTL: d.config.TTL,
+ })
+
+ err = d.client.ChangeTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), records)
+ if err != nil {
+ return fmt.Errorf("beget: failed to create TXT records [domain: %s]: %w",
+ dns01.UnFqdn(info.EffectiveFQDN), 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)
+
+ records, err := d.client.GetTXTRecords(context.Background(), dns01.UnFqdn(info.EffectiveFQDN))
+ if err != nil {
+ return fmt.Errorf("beget: get TXT records: %w", err)
+ }
+
+ if len(records) == 0 {
+ return nil
+ }
+
+ var updatedRecords []internal.Record
+ for _, record := range records {
+ if record.Data == info.Value {
+ continue
+ }
+
+ updatedRecords = append(updatedRecords, record)
+ }
+
+ err = d.client.ChangeTXTRecord(context.Background(), dns01.UnFqdn(info.EffectiveFQDN), updatedRecords)
+ if err != nil {
+ return fmt.Errorf("beget: failed to remove TXT records [domain: %s]: %w",
+ dns01.UnFqdn(info.EffectiveFQDN), err)
+ }
+
+ return nil
+}
diff --git a/providers/dns/beget/beget.toml b/providers/dns/beget/beget.toml
new file mode 100644
index 000000000..3cef2f38c
--- /dev/null
+++ b/providers/dns/beget/beget.toml
@@ -0,0 +1,24 @@
+Name = "Beget.com"
+Description = ''''''
+URL = "https://beget.com/"
+Code = "beget"
+Since = "v4.27.0"
+
+Example = '''
+BEGET_USERNAME=xxxxxx \
+BEGET_PASSWORD=yyyyyy \
+lego --email you@example.com --dns beget -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ BEGET_USERNAME = "API username"
+ BEGET_PASSWORD = "API password"
+ [Configuration.Additional]
+ BEGET_POLLING_INTERVAL = "Time between DNS propagation check in seconds (Default: 30)"
+ BEGET_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation in seconds (Default: 300)"
+ BEGET_TTL = "The TTL of the TXT record used for the DNS challenge in seconds (Default: 120)"
+ BEGET_HTTP_TIMEOUT = "API request timeout in seconds (Default: 30)"
+
+[Links]
+ API = "https://beget.com/ru/kb/api/funkczii-upravleniya-dns"
diff --git a/providers/dns/beget/beget_test.go b/providers/dns/beget/beget_test.go
new file mode 100644
index 000000000..7ceb7b140
--- /dev/null
+++ b/providers/dns/beget/beget_test.go
@@ -0,0 +1,229 @@
+package beget
+
+import (
+ "net/http"
+ "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/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const envDomain = envNamespace + "DOMAIN"
+
+var envTest = tester.NewEnvTest(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{
+ EnvUsername: "123",
+ EnvPassword: "456",
+ },
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "",
+ },
+ expected: "beget: some credentials information are missing: BEGET_USERNAME,BEGET_PASSWORD",
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "456",
+ },
+ expected: "beget: some credentials information are missing: BEGET_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "123",
+ EnvPassword: "",
+ },
+ expected: "beget: some credentials information are missing: BEGET_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.config)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestNewDNSProviderConfig(t *testing.T) {
+ testCases := []struct {
+ desc string
+ username string
+ password string
+ expected string
+ }{
+ {
+ desc: "success",
+ username: "123",
+ password: "456",
+ },
+ {
+ desc: "missing credentials",
+ username: "",
+ password: "",
+ expected: "beget: incomplete credentials, missing username and/or password",
+ },
+ {
+ desc: "missing username",
+ username: "",
+ password: "456",
+ expected: "beget: incomplete credentials, missing username and/or password",
+ },
+ {
+ desc: "missing password",
+ username: "123",
+ password: "",
+ expected: "beget: incomplete credentials, missing username and/or password",
+ },
+ }
+
+ for _, test := range testCases {
+ t.Run(test.desc, func(t *testing.T) {
+ config := NewDefaultConfig()
+ 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.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()
+ assert.NoError(t, err)
+
+ err = provider.Present(envTest.GetDomain(), "", "123d==")
+ assert.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+ provider, err := NewDNSProvider()
+ assert.NoError(t, err)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ assert.NoError(t, err)
+}
+
+func mockBuilder() *servermock.Builder[*DNSProvider] {
+ return servermock.NewBuilder(
+ func(server *httptest.Server) (*DNSProvider, error) {
+ config := NewDefaultConfig()
+ config.Username = "user"
+ config.Password = "secret"
+
+ p, err := NewDNSProviderConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ p.client.HTTPClient = server.Client()
+ p.client.BaseURL, _ = url.Parse(server.URL)
+
+ return p, nil
+ },
+ servermock.CheckQueryParameter().
+ With("login", "user").
+ With("passwd", "secret").
+ With("input_format", "json").
+ With("output_format", "json"),
+ )
+}
+
+func TestDNSProvider_Present(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromInternal("getData-real.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com"}`),
+ ).
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromInternal("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"v=spf1 redirect=beget.com","ttl":300},{"value":"ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY","priority":10,"ttl":300}]}}`),
+ ).
+ Build(t)
+
+ err := provider.Present("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromInternal("getData.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com"}`),
+ ).
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromInternal("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com","records":{"TXT":[{"txtdata":"foo","ttl":300}]}}`),
+ ).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestDNSProvider_CleanUp_empty(t *testing.T) {
+ provider := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromInternal("getData_empty.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"_acme-challenge.example.com"}`),
+ ).
+ Route("/",
+ servermock.Noop().WithStatusCode(http.StatusInternalServerError)).
+ Build(t)
+
+ err := provider.CleanUp("example.com", "", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/beget/internal/client.go b/providers/dns/beget/internal/client.go
new file mode 100644
index 000000000..d8d300606
--- /dev/null
+++ b/providers/dns/beget/internal/client.go
@@ -0,0 +1,135 @@
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
+)
+
+const defaultBaseURL = "https://api.beget.com/api/"
+
+// Client the beget.com client.
+type Client struct {
+ login string
+ password string
+
+ BaseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient Creates a beget.com client.
+func NewClient(login, password string) *Client {
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ login: login,
+ password: password,
+ BaseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 5 * time.Second},
+ }
+}
+
+// GetTXTRecords returns TXT records.
+// https://beget.com/ru/kb/api/funkczii-upravleniya-dns#getdata
+func (c *Client) GetTXTRecords(ctx context.Context, domain string) ([]Record, error) {
+ request := GetRecordsRequest{Fqdn: domain}
+
+ resp, err := c.doRequest(ctx, request, "dns", "getData")
+ if err != nil {
+ return nil, err
+ }
+
+ err = resp.HasError()
+ if err != nil {
+ return nil, err
+ }
+
+ result := GetRecordsResult{}
+
+ err = json.Unmarshal(resp.Answer.Result, &result)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal result: %s: %w", string(resp.Answer.Result), err)
+ }
+
+ return result.Records.TXT, nil
+}
+
+// ChangeTXTRecord changes TXT records.
+// https://beget.com/ru/kb/api/funkczii-upravleniya-dns#changerecords
+func (c *Client) ChangeTXTRecord(ctx context.Context, domain string, records []Record) error {
+ request := ChangeRecordsRequest{
+ Fqdn: domain,
+ Records: RecordList{TXT: records},
+ }
+
+ resp, err := c.doRequest(ctx, request, "dns", "changeRecords")
+ if err != nil {
+ return err
+ }
+
+ return resp.HasError()
+}
+
+func (c *Client) doRequest(ctx context.Context, data any, fragments ...string) (*APIResponse, error) {
+ endpoint := c.BaseURL.JoinPath(fragments...)
+
+ inputData, err := json.Marshal(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to mashall input data: %w", err)
+ }
+
+ query := endpoint.Query()
+ query.Add("input_data", string(inputData))
+ query.Add("login", c.login)
+ query.Add("passwd", c.password)
+ query.Add("input_format", "json")
+ query.Add("output_format", "json")
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), http.NoBody)
+ if err != nil {
+ return nil, fmt.Errorf("unable to create request: %w", err)
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return nil, errutils.NewHTTPDoError(req, err)
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode/100 != 2 {
+ return nil, parseError(req, resp)
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ var apiResp APIResponse
+ err = json.Unmarshal(raw, &apiResp)
+ if err != nil {
+ return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return &apiResp, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ var apiResp APIResponse
+ err := json.Unmarshal(raw, &apiResp)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return fmt.Errorf("[status code %d] %w", resp.StatusCode, apiResp)
+}
diff --git a/providers/dns/beget/internal/client_test.go b/providers/dns/beget/internal/client_test.go
new file mode 100644
index 000000000..4c127abf1
--- /dev/null
+++ b/providers/dns/beget/internal/client_test.go
@@ -0,0 +1,103 @@
+package internal
+
+import (
+ "context"
+ "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("user", "secret")
+
+ client.HTTPClient = server.Client()
+ client.BaseURL, _ = url.Parse(server.URL)
+
+ return client, nil
+ },
+ servermock.CheckQueryParameter().
+ With("login", "user").
+ With("passwd", "secret").
+ With("input_format", "json").
+ With("output_format", "json"),
+ )
+}
+
+func TestClient_GetTXTRecords(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/getData",
+ servermock.ResponseFromFixture("getData-real.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"example.com"}`),
+ ).
+ Build(t)
+
+ data, err := client.GetTXTRecords(context.Background(), "example.com")
+ require.NoError(t, err)
+
+ expected := []Record{{Data: "v=spf1 redirect=beget.com", TTL: 300}}
+
+ assert.Equal(t, expected, data)
+}
+
+func TestClient_ChangeTXTRecord(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"sub.example.com","records":{"TXT":[{"value":"txtTXTtxt","priority":10,"ttl":300}]}}`),
+ ).
+ Build(t)
+
+ records := []Record{{Value: "txtTXTtxt", TTL: 300, Priority: 10}}
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records)
+ require.NoError(t, err)
+}
+
+func TestClient_ChangeTXTRecord_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("error.json")).
+ Build(t)
+
+ records := []Record{{Data: "txtTXTtxt", TTL: 300}}
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "API error: NO_SUCH_METHOD: No such method")
+}
+
+func TestClient_ChangeTXTRecord_answer_error(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("answer_error.json")).
+ Build(t)
+
+ records := []Record{{Data: "txtTXTtxt", TTL: 300}}
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", records)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "API answer error: INVALID_DATA: Login length cannot be greater than 12 characters")
+}
+
+func TestClient_ChangeTXTRecord_remove(t *testing.T) {
+ client := mockBuilder().
+ Route("GET /dns/changeRecords",
+ servermock.ResponseFromFixture("changeRecords-doc.json"),
+ servermock.CheckQueryParameter().
+ With("input_data", `{"fqdn":"sub.example.com","records":{}}`),
+ ).
+ Build(t)
+
+ err := client.ChangeTXTRecord(context.Background(), "sub.example.com", nil)
+ require.NoError(t, err)
+}
diff --git a/providers/dns/beget/internal/fixtures/answer_error.json b/providers/dns/beget/internal/fixtures/answer_error.json
new file mode 100644
index 000000000..12f5fdda7
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/answer_error.json
@@ -0,0 +1,12 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "error",
+ "errors": [
+ {
+ "error_code": "INVALID_DATA",
+ "error_text": "Login length cannot be greater than 12 characters"
+ }
+ ]
+ }
+}
diff --git a/providers/dns/beget/internal/fixtures/changeRecords-doc.json b/providers/dns/beget/internal/fixtures/changeRecords-doc.json
new file mode 100644
index 000000000..4c182d4e6
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/changeRecords-doc.json
@@ -0,0 +1,31 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "A": [
+ {
+ "priority": 10,
+ "value": "127.0.0.1"
+ }
+ ],
+ "MX": [
+ {
+ "priority": 10,
+ "value": "mx1.beget.ru"
+ },
+ {
+ "priority": 20,
+ "value": "mx2.beget.ru"
+ }
+ ],
+ "TXT": [
+ {
+ "priority": 10,
+ "value": "TXT record"
+ }
+ ]
+ }
+ }
+}
+
diff --git a/providers/dns/beget/internal/fixtures/error.json b/providers/dns/beget/internal/fixtures/error.json
new file mode 100644
index 000000000..1dd2a111e
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/error.json
@@ -0,0 +1,5 @@
+{
+ "status": "error",
+ "error_text": "No such method",
+ "error_code": "NO_SUCH_METHOD"
+}
diff --git a/providers/dns/beget/internal/fixtures/getData-doc.json b/providers/dns/beget/internal/fixtures/getData-doc.json
new file mode 100644
index 000000000..bed5b7461
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData-doc.json
@@ -0,0 +1,58 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": 1,
+ "is_beget_dns": 1,
+ "is_subdomain": 0,
+ "fqdn": "beget.ru",
+ "records": {
+ "DNS": [
+ {
+ "value": "ns1.beget.ru",
+ "priority": 10
+ },
+ {
+ "value": "ns2.beget.ru",
+ "priority": 20
+ }
+ ],
+ "DNS_IP": [
+ {
+ "value": null,
+ "priority": 10
+ },
+ {
+ "value": null,
+ "priority": 20
+ }
+ ],
+ "A": [
+ {
+ "value": "91.106.201.65",
+ "priority": "0"
+ }
+ ],
+ "MX": [
+ {
+ "value": "mx1.beget.ru",
+ "priority": "10"
+ },
+ {
+ "value": "mx2.beget.ru",
+ "priority": "20"
+ }
+ ],
+ "TXT": [
+ {
+ "value": "",
+ "priority": 0
+ }
+ ]
+ },
+ "set_type": 1
+ }
+ }
+}
+
diff --git a/providers/dns/beget/internal/fixtures/getData-real.json b/providers/dns/beget/internal/fixtures/getData-real.json
new file mode 100644
index 000000000..700c756e8
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData-real.json
@@ -0,0 +1,67 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": true,
+ "is_beget_dns": true,
+ "is_subdomain": false,
+ "fqdn": "example.com",
+ "records": {
+ "MX": [
+ {
+ "ttl": 300,
+ "exchange": "mx2.beget.com.",
+ "preference": 20
+ },
+ {
+ "ttl": 300,
+ "exchange": "mx1.beget.com.",
+ "preference": 10
+ }
+ ],
+ "TXT": [
+ {
+ "ttl": 300,
+ "txtdata": "v=spf1 redirect=beget.com"
+ }
+ ],
+ "A": [
+ {
+ "ttl": 300,
+ "address": "1.2.3.4"
+ }
+ ],
+ "DNS": [
+ {
+ "value": "ns1.beget.pro"
+ },
+ {
+ "value": "ns2.beget.pro"
+ },
+ {
+ "value": "ns1.beget.com"
+ },
+ {
+ "value": "ns2.beget.com"
+ }
+ ],
+ "DNS_IP": [
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ }
+ ]
+ },
+ "set_type": 1
+ }
+ }
+}
diff --git a/providers/dns/beget/internal/fixtures/getData.json b/providers/dns/beget/internal/fixtures/getData.json
new file mode 100644
index 000000000..571b6ac31
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData.json
@@ -0,0 +1,67 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": true,
+ "is_beget_dns": true,
+ "is_subdomain": false,
+ "fqdn": "_acme-challenge.example.com",
+ "records": {
+ "MX": [
+ {
+ "ttl": 300,
+ "exchange": "mx2.beget.com.",
+ "preference": 20
+ },
+ {
+ "ttl": 300,
+ "exchange": "mx1.beget.com.",
+ "preference": 10
+ }
+ ],
+ "TXT": [
+ {
+ "ttl": 300,
+ "txtdata": "foo"
+ }
+ ],
+ "A": [
+ {
+ "ttl": 300,
+ "address": "1.2.3.4"
+ }
+ ],
+ "DNS": [
+ {
+ "value": "ns1.beget.pro"
+ },
+ {
+ "value": "ns2.beget.pro"
+ },
+ {
+ "value": "ns1.beget.com"
+ },
+ {
+ "value": "ns2.beget.com"
+ }
+ ],
+ "DNS_IP": [
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ },
+ {
+ "value": ""
+ }
+ ]
+ },
+ "set_type": 1
+ }
+ }
+}
diff --git a/providers/dns/beget/internal/fixtures/getData_empty.json b/providers/dns/beget/internal/fixtures/getData_empty.json
new file mode 100644
index 000000000..ea819eeca
--- /dev/null
+++ b/providers/dns/beget/internal/fixtures/getData_empty.json
@@ -0,0 +1,13 @@
+{
+ "status": "success",
+ "answer": {
+ "status": "success",
+ "result": {
+ "is_under_control": true,
+ "is_beget_dns": true,
+ "is_subdomain": false,
+ "fqdn": "_acme-challenge.example.com",
+ "set_type": 1
+ }
+ }
+}
diff --git a/providers/dns/beget/internal/types.go b/providers/dns/beget/internal/types.go
new file mode 100644
index 000000000..90766da79
--- /dev/null
+++ b/providers/dns/beget/internal/types.go
@@ -0,0 +1,100 @@
+package internal
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const successResult = "success"
+
+// APIResponse is the representation of an API response.
+type APIResponse struct {
+ Status string `json:"status"`
+
+ Answer *Answer `json:"answer,omitempty"`
+
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+}
+
+func (a APIResponse) Error() string {
+ return fmt.Sprintf("API %s: %s: %s", a.Status, a.ErrorCode, a.ErrorText)
+}
+
+// HasError returns an error is the response contains an error.
+func (a APIResponse) HasError() error {
+ if a.Status != successResult {
+ return a
+ }
+
+ if a.Answer == nil || a.Status != successResult || a.Answer.Status != successResult {
+ return a.Answer
+ }
+
+ return nil
+}
+
+// Answer is the representation of an API response answer.
+type Answer struct {
+ Status string `json:"status,omitempty"`
+ Result json.RawMessage `json:"result,omitempty"`
+
+ Errors []AnswerError `json:"errors,omitempty"`
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+}
+
+type AnswerError struct {
+ ErrorCode string `json:"error_code,omitempty"`
+ ErrorText string `json:"error_text,omitempty"`
+}
+
+func (a Answer) Error() string {
+ parts := []string{fmt.Sprintf("API answer %s", a.Status)}
+
+ if a.ErrorCode != "" {
+ parts = append(parts, a.ErrorCode)
+ }
+
+ if a.ErrorText != "" {
+ parts = append(parts, a.ErrorText)
+ }
+
+ if len(a.Errors) > 0 {
+ for _, e := range a.Errors {
+ parts = append(parts, e.ErrorCode, e.ErrorText)
+ }
+ }
+
+ return strings.Join(parts, ": ")
+}
+
+// GetRecordsRequest data representation for data get request.
+type GetRecordsRequest struct {
+ Fqdn string `json:"fqdn,omitempty"`
+}
+
+// ChangeRecordsRequest data representation for data change request.
+type ChangeRecordsRequest struct {
+ Fqdn string `json:"fqdn,omitempty"`
+ Records RecordList `json:"records,omitempty"`
+}
+
+// RecordList List of entries (in this case only described TXT).
+type RecordList struct {
+ TXT []Record `json:"TXT,omitempty"`
+}
+
+// Record data representation for TXT record.
+type Record struct {
+ Value string `json:"value,omitempty"`
+ Data string `json:"txtdata,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ TTL int `json:"ttl,omitempty"`
+}
+
+type GetRecordsResult struct {
+ Fqdn string `json:"fqdn"`
+ Records RecordList `json:"records"`
+}
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index 1e6586e89..df0c59bf6 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -19,6 +19,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/azure"
"github.com/go-acme/lego/v4/providers/dns/azuredns"
"github.com/go-acme/lego/v4/providers/dns/baiducloud"
+ "github.com/go-acme/lego/v4/providers/dns/beget"
"github.com/go-acme/lego/v4/providers/dns/binarylane"
"github.com/go-acme/lego/v4/providers/dns/bindman"
"github.com/go-acme/lego/v4/providers/dns/bluecat"
@@ -199,6 +200,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return azuredns.NewDNSProvider()
case "baiducloud":
return baiducloud.NewDNSProvider()
+ case "beget":
+ return beget.NewDNSProvider()
case "binarylane":
return binarylane.NewDNSProvider()
case "bindman":