1
0
mirror of https://github.com/go-acme/lego.git synced 2025-04-09 01:13:59 +02:00

Add DNS provider for West.cn/西部数码 (#2318)

This commit is contained in:
Ludovic Fernandez 2024-11-21 17:47:07 +01:00 committed by GitHub
parent 6fccca616a
commit b34902160d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 905 additions and 4 deletions

View File

@ -224,13 +224,13 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns).
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/websupport/">Websupport</a></td>
<td><a href="https://go-acme.github.io/lego/dns/wedos/">WEDOS</a></td>
<td><a href="https://go-acme.github.io/lego/dns/westcn/">West.cn/西部数码</a></td>
<td><a href="https://go-acme.github.io/lego/dns/yandex360/">Yandex 360</a></td>
<td><a href="https://go-acme.github.io/lego/dns/yandexcloud/">Yandex Cloud</a></td>
</tr><tr>
<td><a href="https://go-acme.github.io/lego/dns/yandexcloud/">Yandex Cloud</a></td>
<td><a href="https://go-acme.github.io/lego/dns/yandex/">Yandex PDD</a></td>
<td><a href="https://go-acme.github.io/lego/dns/zoneee/">Zone.ee</a></td>
<td><a href="https://go-acme.github.io/lego/dns/zonomi/">Zonomi</a></td>
<td></td>
</tr></table>
<!-- END DNS PROVIDERS LIST -->

View File

@ -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.`)

View File

@ -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"
---
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/westcn/westcn.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
Configuration for [West.cn/西部数码](https://www.west.cn).
<!--more-->
- 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)
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->
<!-- providers/dns/westcn/westcn.toml -->
<!-- THIS DOCUMENTATION IS AUTO-GENERATED. PLEASE DO NOT EDIT. -->

View File

@ -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
"""

2
go.mod
View File

@ -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

View File

@ -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()))
}

View File

@ -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"))
}

View File

@ -0,0 +1,7 @@
{
"result": 200,
"clientid": "54880064508339547956",
"data": {
"id": 123456
}
}

View File

@ -0,0 +1,4 @@
{
"result": 200,
"clientid": "54880064508339547956"
}

View File

@ -0,0 +1,6 @@
{
"result": 500,
"clientid": "54880064508339547956",
"msg": "username,time,token±Ø´«",
"errcode": 10000
}

View File

@ -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"`
}

View File

@ -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
}

View File

@ -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"

View File

@ -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)
}

View File

@ -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":