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:
parent
6fccca616a
commit
b34902160d
@ -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 -->
|
||||
|
@ -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.`)
|
||||
|
69
docs/content/dns/zz_gen_westcn.md
Normal file
69
docs/content/dns/zz_gen_westcn.md
Normal 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. -->
|
@ -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
2
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
|
||||
|
211
providers/dns/westcn/internal/client.go
Normal file
211
providers/dns/westcn/internal/client.go
Normal 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()))
|
||||
}
|
215
providers/dns/westcn/internal/client_test.go
Normal file
215
providers/dns/westcn/internal/client_test.go
Normal 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"))
|
||||
}
|
7
providers/dns/westcn/internal/fixtures/adddnsrecord.json
Normal file
7
providers/dns/westcn/internal/fixtures/adddnsrecord.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"result": 200,
|
||||
"clientid": "54880064508339547956",
|
||||
"data": {
|
||||
"id": 123456
|
||||
}
|
||||
}
|
4
providers/dns/westcn/internal/fixtures/deldnsrecord.json
Normal file
4
providers/dns/westcn/internal/fixtures/deldnsrecord.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"result": 200,
|
||||
"clientid": "54880064508339547956"
|
||||
}
|
6
providers/dns/westcn/internal/fixtures/error.json
Normal file
6
providers/dns/westcn/internal/fixtures/error.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"result": 500,
|
||||
"clientid": "54880064508339547956",
|
||||
"msg": "username,time,token±Ø´«",
|
||||
"errcode": 10000
|
||||
}
|
28
providers/dns/westcn/internal/types.go
Normal file
28
providers/dns/westcn/internal/types.go
Normal 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"`
|
||||
}
|
169
providers/dns/westcn/westcn.go
Normal file
169
providers/dns/westcn/westcn.go
Normal 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
|
||||
}
|
24
providers/dns/westcn/westcn.toml
Normal file
24
providers/dns/westcn/westcn.toml
Normal 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"
|
143
providers/dns/westcn/westcn_test.go
Normal file
143
providers/dns/westcn/westcn_test.go
Normal 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)
|
||||
}
|
@ -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":
|
||||
|
Loading…
x
Reference in New Issue
Block a user