diff --git a/README.md b/README.md
index 0e4cf617c4..53e9e529eb 100644
--- a/README.md
+++ b/README.md
@@ -224,13 +224,13 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
Websupport |
WEDOS |
+ West.cn/西部数码 |
Yandex 360 |
- Yandex Cloud |
+ Yandex Cloud |
Yandex PDD |
Zone.ee |
Zonomi |
- |
diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go
index 1a9d0fa557..b7f6e6c8c6 100644
--- a/cmd/zz_gen_cmd_dnshelp.go
+++ b/cmd/zz_gen_cmd_dnshelp.go
@@ -150,6 +150,7 @@ func allDNSCodes() string {
"webnames",
"websupport",
"wedos",
+ "westcn",
"yandex",
"yandex360",
"yandexcloud",
@@ -3113,6 +3114,27 @@ func displayDNSHelp(w io.Writer, name string) error {
ew.writeln()
ew.writeln(`More information: https://go-acme.github.io/lego/dns/wedos`)
+ case "westcn":
+ // generated from: providers/dns/westcn/westcn.toml
+ ew.writeln(`Configuration for West.cn/西部数码.`)
+ ew.writeln(`Code: 'westcn'`)
+ ew.writeln(`Since: 'v4.21.0'`)
+ ew.writeln()
+
+ ew.writeln(`Credentials:`)
+ ew.writeln(` - "WESTCN_PASSWORD": API password`)
+ ew.writeln(` - "WESTCN_USERNAME": Username`)
+ ew.writeln()
+
+ ew.writeln(`Additional Configuration:`)
+ ew.writeln(` - "WESTCN_HTTP_TIMEOUT": API request timeout`)
+ ew.writeln(` - "WESTCN_POLLING_INTERVAL": Time between DNS propagation check`)
+ ew.writeln(` - "WESTCN_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`)
+ ew.writeln(` - "WESTCN_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/westcn`)
+
case "yandex":
// generated from: providers/dns/yandex/yandex.toml
ew.writeln(`Configuration for Yandex PDD.`)
diff --git a/docs/content/dns/zz_gen_westcn.md b/docs/content/dns/zz_gen_westcn.md
new file mode 100644
index 0000000000..fdda3b2467
--- /dev/null
+++ b/docs/content/dns/zz_gen_westcn.md
@@ -0,0 +1,69 @@
+---
+title: "West.cn/西部数码"
+date: 2019-03-03T16:39:46+01:00
+draft: false
+slug: westcn
+dnsprovider:
+ since: "v4.21.0"
+ code: "westcn"
+ url: "https://www.west.cn"
+---
+
+
+
+
+
+
+Configuration for [West.cn/西部数码](https://www.west.cn).
+
+
+
+
+- Code: `westcn`
+- Since: v4.21.0
+
+
+Here is an example bash command using the West.cn/西部数码 provider:
+
+```bash
+WESTCN_USERNAME="xxx" \
+WESTCN_PASSWORD="yyy" \
+lego --email you@example.com --dns westcn -d '*.example.com' -d example.com run
+```
+
+
+
+
+## Credentials
+
+| Environment Variable Name | Description |
+|-----------------------|-------------|
+| `WESTCN_PASSWORD` | API password |
+| `WESTCN_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 |
+|--------------------------------|-------------|
+| `WESTCN_HTTP_TIMEOUT` | API request timeout |
+| `WESTCN_POLLING_INTERVAL` | Time between DNS propagation check |
+| `WESTCN_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |
+| `WESTCN_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" %}}).
+
+
+
+
+## More information
+
+- [API documentation](https://www.west.cn/CustomerCenter/doc/domain_v2.html)
+
+
+
+
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index dd086b795d..b6eec239b9 100644
--- a/docs/data/zz_cli_help.toml
+++ b/docs/data/zz_cli_help.toml
@@ -142,7 +142,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, corenetworks, 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, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, 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, corenetworks, 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, rainyun, rcodezero, regfish, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, selectelv2, selfhostde, servercow, shellrent, simply, sonic, stackpath, technitium, tencentcloud, timewebcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, volcengine, vscale, vultr, webnames, websupport, wedos, westcn, yandex, yandex360, yandexcloud, zoneee, zonomi
More information: https://go-acme.github.io/lego/dns
"""
diff --git a/go.mod b/go.mod
index ed000aac2d..052b4dd290 100644
--- a/go.mod
+++ b/go.mod
@@ -83,6 +83,7 @@ require (
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/oauth2 v0.23.0
+ golang.org/x/text v0.19.0
golang.org/x/time v0.7.0
google.golang.org/api v0.204.0
gopkg.in/ns1/ns1-go.v2 v2.12.2
@@ -198,7 +199,6 @@ require (
golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
- golang.org/x/text v0.19.0 // indirect
golang.org/x/tools v0.25.0 // indirect
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect
diff --git a/providers/dns/westcn/internal/client.go b/providers/dns/westcn/internal/client.go
new file mode 100644
index 0000000000..4d967f5e17
--- /dev/null
+++ b/providers/dns/westcn/internal/client.go
@@ -0,0 +1,211 @@
+package internal
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ querystring "github.com/google/go-querystring/query"
+ "github.com/nrdcg/mailinabox/errutils"
+ "golang.org/x/text/encoding"
+ "golang.org/x/text/encoding/simplifiedchinese"
+ "golang.org/x/text/transform"
+)
+
+const defaultBaseURL = "https://api.west.cn/api/v2"
+
+// Client the West.cn API client.
+type Client struct {
+ username string
+ password string
+
+ encoder *encoding.Encoder
+
+ baseURL *url.URL
+ HTTPClient *http.Client
+}
+
+// NewClient creates a new Client.
+func NewClient(username, password string) (*Client, error) {
+ if username == "" || password == "" {
+ return nil, errors.New("credentials missing")
+ }
+
+ baseURL, _ := url.Parse(defaultBaseURL)
+
+ return &Client{
+ username: username,
+ password: password,
+ encoder: simplifiedchinese.GBK.NewEncoder(),
+ baseURL: baseURL,
+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
+ }, nil
+}
+
+// AddRecord adds a record.
+// https://www.west.cn/CustomerCenter/doc/domain_v2.html#37u3001u6dfbu52a0u57dfu540du89e3u67900a3ca20id3d37u3001u6dfbu52a0u57dfu540du89e3u67903e203ca3e
+func (c *Client) AddRecord(ctx context.Context, record Record) (int, error) {
+ values, err := querystring.Values(record)
+ if err != nil {
+ return 0, err
+ }
+
+ req, err := c.newRequest(ctx, "domain", "adddnsrecord", values)
+ if err != nil {
+ return 0, err
+ }
+
+ results := &APIResponse[RecordID]{}
+
+ err = c.do(req, results)
+ if err != nil {
+ return 0, err
+ }
+
+ if results.Result != http.StatusOK {
+ return 0, results
+ }
+
+ return results.Data.ID, nil
+}
+
+// DeleteRecord deleted a record.
+// https://www.west.cn/CustomerCenter/doc/domain_v2.html#39u3001u5220u9664u57dfu540du89e3u67900a3ca20id3d39u3001u5220u9664u57dfu540du89e3u67903e203ca3e
+func (c *Client) DeleteRecord(ctx context.Context, domain string, recordID int) error {
+ values := url.Values{}
+ values.Set("domain", domain)
+ values.Set("id", strconv.Itoa(recordID))
+
+ req, err := c.newRequest(ctx, "domain", "deldnsrecord", values)
+ if err != nil {
+ return err
+ }
+
+ results := &APIResponse[any]{}
+
+ err = c.do(req, results)
+ if err != nil {
+ return err
+ }
+
+ if results.Result != http.StatusOK {
+ return results
+ }
+
+ return nil
+}
+
+func (c *Client) newRequest(ctx context.Context, p, act string, form url.Values) (*http.Request, error) {
+ if form == nil {
+ form = url.Values{}
+ }
+
+ c.sign(form, time.Now())
+
+ values, err := c.convertURLValues(form)
+ if err != nil {
+ return nil, err
+ }
+
+ endpoint := c.baseURL.JoinPath(p, "/")
+
+ query := endpoint.Query()
+ query.Set("act", act)
+ endpoint.RawQuery = query.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(values.Encode()))
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ return req, nil
+}
+
+func (c *Client) sign(form url.Values, now time.Time) {
+ timestamp := strconv.FormatInt(now.UnixMilli(), 10)
+
+ sum := md5.Sum([]byte(c.username + c.password + timestamp))
+
+ form.Set("token", hex.EncodeToString(sum[:]))
+ form.Set("username", c.username)
+ form.Set("time", timestamp)
+}
+
+func (c *Client) do(req *http.Request, result any) error {
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return err
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return parseError(req, resp)
+ }
+
+ if result == nil {
+ return nil
+ }
+
+ raw, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return errutils.NewReadResponseError(req, resp.StatusCode, err)
+ }
+
+ err = gbkDecoder(raw).Decode(result)
+ if err != nil {
+ return errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
+ }
+
+ return nil
+}
+
+func (c *Client) convertURLValues(values url.Values) (url.Values, error) {
+ results := make(url.Values)
+
+ for key, vs := range values {
+ encKey, err := c.encoder.String(key)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, value := range vs {
+ encValue, err := c.encoder.String(value)
+ if err != nil {
+ return nil, err
+ }
+
+ results.Add(encKey, encValue)
+ }
+ }
+
+ return results, nil
+}
+
+func parseError(req *http.Request, resp *http.Response) error {
+ raw, _ := io.ReadAll(resp.Body)
+
+ result := &APIResponse[any]{}
+
+ err := gbkDecoder(raw).Decode(result)
+ if err != nil {
+ return errutils.NewUnexpectedStatusCodeError(req, resp.StatusCode, raw)
+ }
+
+ return result
+}
+
+func gbkDecoder(raw []byte) *json.Decoder {
+ return json.NewDecoder(transform.NewReader(bytes.NewBuffer(raw), simplifiedchinese.GBK.NewDecoder()))
+}
diff --git a/providers/dns/westcn/internal/client_test.go b/providers/dns/westcn/internal/client_test.go
new file mode 100644
index 0000000000..ed0c7dc1af
--- /dev/null
+++ b/providers/dns/westcn/internal/client_test.go
@@ -0,0 +1,215 @@
+package internal
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/encoding/simplifiedchinese"
+)
+
+type formExpectation func(values url.Values) error
+
+func setupTest(t *testing.T, filename string, expectations ...formExpectation) *Client {
+ t.Helper()
+
+ mux := http.NewServeMux()
+ server := httptest.NewServer(mux)
+ t.Cleanup(server.Close)
+
+ mux.HandleFunc("POST /", func(rw http.ResponseWriter, req *http.Request) {
+ err := req.ParseForm()
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ commons := []formExpectation{
+ expectValue("username", "user"),
+ expectNotEmpty("time"),
+ expectNotEmpty("token"),
+ }
+
+ for _, common := range commons {
+ err = common(req.Form)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ for _, expectation := range expectations {
+ err = expectation(req.Form)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusBadRequest)
+ return
+ }
+ }
+
+ rw.Header().Set("Content-Type", "application/json; Charset=gb2312")
+
+ file, err := os.Open(filepath.Join("fixtures", filename))
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ defer func() { _ = file.Close() }()
+
+ rw.WriteHeader(http.StatusOK)
+ _, err = io.Copy(rw, file)
+ if err != nil {
+ http.Error(rw, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ })
+
+ client, err := NewClient("user", "secret")
+ require.NoError(t, err)
+
+ client.HTTPClient = server.Client()
+ client.baseURL, _ = url.Parse(server.URL)
+
+ return client
+}
+
+func expectValue(key, value string) formExpectation {
+ return func(values url.Values) error {
+ if values.Get(key) != value {
+ return fmt.Errorf("expected %s, got %s", value, values.Get(key))
+ }
+
+ return nil
+ }
+}
+
+func expectNotEmpty(key string) formExpectation {
+ return func(values url.Values) error {
+ if values.Get(key) == "" {
+ return fmt.Errorf("%s missing", key)
+ }
+
+ return nil
+ }
+}
+
+func noop() formExpectation {
+ return func(_ url.Values) error {
+ return nil
+ }
+}
+
+func TestClientAddRecord(t *testing.T) {
+ expectValue("act", "adddnsrecord")
+
+ client := setupTest(t, "adddnsrecord.json",
+ expectValue("act", "adddnsrecord"),
+ expectValue("domain", "example.com"),
+ expectValue("host", "@"),
+ expectValue("type", "TXT"),
+ expectValue("value", "txtTXTtxt"),
+ expectValue("ttl", "60"),
+ )
+
+ record := Record{
+ Domain: "example.com",
+ Host: "@",
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ TTL: 60,
+ }
+
+ id, err := client.AddRecord(context.Background(), record)
+ require.NoError(t, err)
+
+ assert.Equal(t, 123456, id)
+}
+
+func TestClientAddRecord_error(t *testing.T) {
+ client := setupTest(t, "error.json", noop())
+
+ record := Record{
+ Domain: "example.com",
+ Host: "@",
+ Type: "TXT",
+ Value: "txtTXTtxt",
+ TTL: 60,
+ }
+
+ _, err := client.AddRecord(context.Background(), record)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "10000: username,time,token必传 (500)")
+}
+
+func TestClientDeleteRecord(t *testing.T) {
+ client := setupTest(t, "deldnsrecord.json",
+ expectValue("act", "deldnsrecord"),
+ expectValue("domain", "example.com"),
+ )
+
+ err := client.DeleteRecord(context.Background(), "example.com", 123)
+ require.NoError(t, err)
+}
+
+func TestClientDeleteRecord_error(t *testing.T) {
+ client := setupTest(t, "error.json", noop())
+
+ err := client.DeleteRecord(context.Background(), "example.com", 123)
+ require.Error(t, err)
+
+ require.EqualError(t, err, "10000: username,time,token必传 (500)")
+}
+
+func Test_convertURLValues(t *testing.T) {
+ client, err := NewClient("user", "secret")
+ require.NoError(t, err)
+
+ key := "你好abc"
+ value := "世界def"
+
+ form := url.Values{}
+ form.Set(key, value)
+
+ values, err := client.convertURLValues(form)
+ require.NoError(t, err)
+
+ encoder := simplifiedchinese.GBK.NewEncoder()
+
+ k, err := encoder.String(key)
+ require.NoError(t, err)
+
+ v, err := encoder.String(value)
+ require.NoError(t, err)
+
+ assert.Equal(t, v, values.Get(k))
+
+ decoder := simplifiedchinese.GBK.NewDecoder()
+
+ decValue, err := decoder.String(values.Get(k))
+ require.NoError(t, err)
+
+ assert.Equal(t, value, decValue)
+}
+
+func TestClient_sign(t *testing.T) {
+ client, err := NewClient("zhangsan", "5dh232kfg!*")
+ require.NoError(t, err)
+
+ form := url.Values{}
+
+ client.sign(form, time.UnixMilli(1554691950854))
+
+ assert.Equal(t, "zhangsan", form.Get("username"))
+ assert.Equal(t, "1554691950854", form.Get("time"))
+ assert.Equal(t, "f17581fb2535b2a7ee4468eb3f96a2a9", form.Get("token"))
+}
diff --git a/providers/dns/westcn/internal/fixtures/adddnsrecord.json b/providers/dns/westcn/internal/fixtures/adddnsrecord.json
new file mode 100644
index 0000000000..f1c1352060
--- /dev/null
+++ b/providers/dns/westcn/internal/fixtures/adddnsrecord.json
@@ -0,0 +1,7 @@
+{
+ "result": 200,
+ "clientid": "54880064508339547956",
+ "data": {
+ "id": 123456
+ }
+}
diff --git a/providers/dns/westcn/internal/fixtures/deldnsrecord.json b/providers/dns/westcn/internal/fixtures/deldnsrecord.json
new file mode 100644
index 0000000000..e97e92f74d
--- /dev/null
+++ b/providers/dns/westcn/internal/fixtures/deldnsrecord.json
@@ -0,0 +1,4 @@
+{
+ "result": 200,
+ "clientid": "54880064508339547956"
+}
diff --git a/providers/dns/westcn/internal/fixtures/error.json b/providers/dns/westcn/internal/fixtures/error.json
new file mode 100644
index 0000000000..1c92415de2
--- /dev/null
+++ b/providers/dns/westcn/internal/fixtures/error.json
@@ -0,0 +1,6 @@
+{
+ "result": 500,
+ "clientid": "54880064508339547956",
+ "msg": "username,time,tokenش",
+ "errcode": 10000
+}
diff --git a/providers/dns/westcn/internal/types.go b/providers/dns/westcn/internal/types.go
new file mode 100644
index 0000000000..d8d66be2ca
--- /dev/null
+++ b/providers/dns/westcn/internal/types.go
@@ -0,0 +1,28 @@
+package internal
+
+import "fmt"
+
+type APIResponse[T any] struct {
+ Result int `json:"result,omitempty"`
+ ClientID string `json:"clientid,omitempty"`
+ Message string `json:"msg,omitempty"`
+ ErrorCode int `json:"errcode,omitempty"`
+ Data T `json:"data,omitempty"`
+}
+
+func (a APIResponse[T]) Error() string {
+ return fmt.Sprintf("%d: %s (%d)", a.ErrorCode, a.Message, a.Result)
+}
+
+type Record struct {
+ Domain string `url:"domain,omitempty"`
+ Host string `url:"host,omitempty"`
+ Type string `url:"type,omitempty"`
+ Value string `url:"value,omitempty"`
+ TTL int `url:"ttl,omitempty"` // 60~86400 seconds
+ Priority int `url:"level,omitempty"`
+}
+
+type RecordID struct {
+ ID int `json:"id,omitempty"`
+}
diff --git a/providers/dns/westcn/westcn.go b/providers/dns/westcn/westcn.go
new file mode 100644
index 0000000000..37f357b70e
--- /dev/null
+++ b/providers/dns/westcn/westcn.go
@@ -0,0 +1,169 @@
+// Package westcn implements a DNS provider for solving the DNS-01 challenge using West.cn/西部数码.
+package westcn
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+ "github.com/go-acme/lego/v4/platform/config/env"
+ "github.com/go-acme/lego/v4/providers/dns/westcn/internal"
+)
+
+// Environment variables names.
+const (
+ envNamespace = "WESTCN_"
+
+ 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, 60),
+ PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
+ PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
+ HTTPClient: &http.Client{
+ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
+ },
+ }
+}
+
+// DNSProvider implements the challenge.Provider interface.
+type DNSProvider struct {
+ config *Config
+ client *internal.Client
+
+ recordIDs map[string]int
+ recordIDsMu sync.Mutex
+}
+
+// NewDNSProvider returns a DNSProvider instance configured for West.cn/西部数码.
+func NewDNSProvider() (*DNSProvider, error) {
+ values, err := env.Get(EnvUsername, EnvPassword)
+ if err != nil {
+ return nil, fmt.Errorf("westcn: %w", err)
+ }
+
+ config := NewDefaultConfig()
+ config.Username = values[EnvUsername]
+ config.Password = values[EnvPassword]
+
+ return NewDNSProviderConfig(config)
+}
+
+// NewDNSProviderConfig return a DNSProvider instance configured for West.cn/西部数码.
+func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
+ if config == nil {
+ return nil, errors.New("westcn: the configuration of the DNS provider is nil")
+ }
+
+ client, err := internal.NewClient(config.Username, config.Password)
+ if err != nil {
+ return nil, fmt.Errorf("westcn: %w", err)
+ }
+
+ if config.HTTPClient != nil {
+ client.HTTPClient = config.HTTPClient
+ }
+
+ return &DNSProvider{
+ config: config,
+ client: client,
+ recordIDs: make(map[string]int),
+ }, nil
+}
+
+// Present creates a TXT record using the specified parameters.
+func (d *DNSProvider) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("westcn: could not find zone for domain %q: %w", domain, err)
+ }
+
+ subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
+ if err != nil {
+ return fmt.Errorf("westcn: %w", err)
+ }
+
+ record := internal.Record{
+ Domain: dns01.UnFqdn(authZone),
+ Host: subDomain,
+ Type: "TXT",
+ Value: info.Value,
+ TTL: d.config.TTL,
+ }
+
+ recordID, err := d.client.AddRecord(context.Background(), record)
+ if err != nil {
+ return fmt.Errorf("westcn: add record: %w", err)
+ }
+
+ d.recordIDsMu.Lock()
+ d.recordIDs[token] = recordID
+ d.recordIDsMu.Unlock()
+
+ return nil
+}
+
+// CleanUp removes the TXT record matching the specified parameters.
+func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+
+ authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
+ if err != nil {
+ return fmt.Errorf("westcn: could not find zone for domain %q: %w", domain, err)
+ }
+
+ // gets the record's unique ID
+ d.recordIDsMu.Lock()
+ recordID, ok := d.recordIDs[token]
+ d.recordIDsMu.Unlock()
+ if !ok {
+ return fmt.Errorf("westcn: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
+ }
+
+ err = d.client.DeleteRecord(context.Background(), dns01.UnFqdn(authZone), recordID)
+ if err != nil {
+ return fmt.Errorf("westcn: delete record: %w", err)
+ }
+
+ // deletes record ID from map
+ d.recordIDsMu.Lock()
+ delete(d.recordIDs, token)
+ d.recordIDsMu.Unlock()
+
+ return nil
+}
+
+// Timeout returns the timeout and interval to use when checking for DNS propagation.
+// Adjusting here to cope with spikes in propagation times.
+func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
+ return d.config.PropagationTimeout, d.config.PollingInterval
+}
diff --git a/providers/dns/westcn/westcn.toml b/providers/dns/westcn/westcn.toml
new file mode 100644
index 0000000000..3b3914eacd
--- /dev/null
+++ b/providers/dns/westcn/westcn.toml
@@ -0,0 +1,24 @@
+Name = "West.cn/西部数码"
+Description = ''''''
+URL = "https://www.west.cn"
+Code = "westcn"
+Since = "v4.21.0"
+
+Example = '''
+WESTCN_USERNAME="xxx" \
+WESTCN_PASSWORD="yyy" \
+lego --email you@example.com --dns westcn -d '*.example.com' -d example.com run
+'''
+
+[Configuration]
+ [Configuration.Credentials]
+ WESTCN_USERNAME = "Username"
+ WESTCN_PASSWORD = "API password"
+ [Configuration.Additional]
+ WESTCN_POLLING_INTERVAL = "Time between DNS propagation check"
+ WESTCN_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation"
+ WESTCN_TTL = "The TTL of the TXT record used for the DNS challenge"
+ WESTCN_HTTP_TIMEOUT = "API request timeout"
+
+[Links]
+ API = "https://www.west.cn/CustomerCenter/doc/domain_v2.html"
diff --git a/providers/dns/westcn/westcn_test.go b/providers/dns/westcn/westcn_test.go
new file mode 100644
index 0000000000..71632d99f6
--- /dev/null
+++ b/providers/dns/westcn/westcn_test.go
@@ -0,0 +1,143 @@
+package westcn
+
+import (
+ "testing"
+
+ "github.com/go-acme/lego/v4/platform/tester"
+ "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: "user",
+ EnvPassword: "secret",
+ },
+ },
+ {
+ desc: "missing username",
+ envVars: map[string]string{
+ EnvUsername: "",
+ EnvPassword: "secret",
+ },
+ expected: "westcn: some credentials information are missing: WESTCN_USERNAME",
+ },
+ {
+ desc: "missing password",
+ envVars: map[string]string{
+ EnvUsername: "user",
+ EnvPassword: "",
+ },
+ expected: "westcn: some credentials information are missing: WESTCN_PASSWORD",
+ },
+ {
+ desc: "missing credentials",
+ envVars: map[string]string{},
+ expected: "westcn: some credentials information are missing: WESTCN_USERNAME,WESTCN_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)
+ require.NotNil(t, p.client)
+ } 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: "user",
+ password: "secret",
+ },
+ {
+ desc: "missing username",
+ password: "secret",
+ expected: "westcn: credentials missing",
+ },
+ {
+ desc: "missing password",
+ username: "user",
+ expected: "westcn: credentials missing",
+ },
+ {
+ desc: "missing credentials",
+ expected: "westcn: credentials missing",
+ },
+ }
+
+ 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)
+ require.NotNil(t, p.client)
+ } else {
+ require.EqualError(t, err, test.expected)
+ }
+ })
+ }
+}
+
+func TestLivePresent(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.Present(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
+
+func TestLiveCleanUp(t *testing.T) {
+ if !envTest.IsLiveTest() {
+ t.Skip("skipping live test")
+ }
+
+ envTest.RestoreEnv()
+ provider, err := NewDNSProvider()
+ require.NoError(t, err)
+
+ err = provider.CleanUp(envTest.GetDomain(), "", "123d==")
+ require.NoError(t, err)
+}
diff --git a/providers/dns/zz_gen_dns_providers.go b/providers/dns/zz_gen_dns_providers.go
index 701fc83ffd..a60b48b701 100644
--- a/providers/dns/zz_gen_dns_providers.go
+++ b/providers/dns/zz_gen_dns_providers.go
@@ -144,6 +144,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/webnames"
"github.com/go-acme/lego/v4/providers/dns/websupport"
"github.com/go-acme/lego/v4/providers/dns/wedos"
+ "github.com/go-acme/lego/v4/providers/dns/westcn"
"github.com/go-acme/lego/v4/providers/dns/yandex"
"github.com/go-acme/lego/v4/providers/dns/yandex360"
"github.com/go-acme/lego/v4/providers/dns/yandexcloud"
@@ -430,6 +431,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) {
return websupport.NewDNSProvider()
case "wedos":
return wedos.NewDNSProvider()
+ case "westcn":
+ return westcn.NewDNSProvider()
case "yandex":
return yandex.NewDNSProvider()
case "yandex360":