mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2024-11-10 09:12:47 +08:00
NEW PROVIDER: deSEC (#725)
* Add initial deSEC support * Handle the api rate limiting * Fix deleteRR and do some code cleanup * improve rate limiting and record deletion * Add documentation for deSEC provider * README.md update list of supported DNS providers * deSEC supports SSHFP records * dynamic minimum_ttl and hint for DNSSec on domain creation * merge all changes into one single bulk api request * Fix: actually set the TTL to min_ttl if necessary * use a constant for apiBase URL * Fix code comments * Use PUT instead of PATCH for upsertRR method * use ' instead of " for java script examples
This commit is contained in:
parent
5416c16fa1
commit
207f050911
6 changed files with 552 additions and 0 deletions
|
@ -20,6 +20,7 @@ Currently supported DNS providers:
|
|||
- BIND
|
||||
- ClouDNS
|
||||
- Cloudflare
|
||||
- deSEC
|
||||
- DNSimple
|
||||
- DigitalOcean
|
||||
- Exoscale
|
||||
|
|
36
docs/_providers/desec.md
Normal file
36
docs/_providers/desec.md
Normal file
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
name: deSEC
|
||||
title: deSEC Provider
|
||||
layout: default
|
||||
jsId: DESEC
|
||||
---
|
||||
# deSEC Provider
|
||||
## Configuration
|
||||
In your providers credentials file you must provide a deSEC account auth token:
|
||||
|
||||
{% highlight json %}
|
||||
{
|
||||
"desec": {
|
||||
"auth-token": "your-deSEC-auth-token"
|
||||
}
|
||||
}
|
||||
{% endhighlight %}
|
||||
|
||||
## Metadata
|
||||
This provider does not recognize any special metadata fields unique to deSEC.
|
||||
|
||||
## Usage
|
||||
Example Javascript:
|
||||
|
||||
{% highlight js %}
|
||||
var REG_NONE = NewRegistrar('none', 'NONE'); // No registrar.
|
||||
var deSEC = NewDnsProvider('desec', 'DESEC'); // deSEC
|
||||
|
||||
D('example.tld', REG_NONE, DnsProvider(deSEC),
|
||||
A('test','1.2.3.4')
|
||||
);
|
||||
{% endhighlight %}
|
||||
|
||||
## Activation
|
||||
DNSControl depends on a deSEC account auth token.
|
||||
This token can be obtained by logging in via the deSEC API: https://desec.readthedocs.io/en/latest/auth/account.html#log-in
|
|
@ -8,6 +8,7 @@ import (
|
|||
_ "github.com/StackExchange/dnscontrol/v3/providers/bind"
|
||||
_ "github.com/StackExchange/dnscontrol/v3/providers/cloudflare"
|
||||
_ "github.com/StackExchange/dnscontrol/v3/providers/cloudns"
|
||||
_ "github.com/StackExchange/dnscontrol/v3/providers/desec"
|
||||
_ "github.com/StackExchange/dnscontrol/v3/providers/digitalocean"
|
||||
_ "github.com/StackExchange/dnscontrol/v3/providers/dnsimple"
|
||||
_ "github.com/StackExchange/dnscontrol/v3/providers/exoscale"
|
||||
|
|
79
providers/desec/convert.go
Normal file
79
providers/desec/convert.go
Normal file
|
@ -0,0 +1,79 @@
|
|||
package desec
|
||||
|
||||
// Convert the provider's native record description to models.RecordConfig.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v3/models"
|
||||
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
|
||||
)
|
||||
|
||||
// nativeToRecord takes a DNS record from deSEC and returns a native RecordConfig struct.
|
||||
func nativeToRecords(n resourceRecord, origin string) (rcs []*models.RecordConfig) {
|
||||
|
||||
// deSEC returns all the values for a given label/rtype pair in each
|
||||
// resourceRecord. In other words, if there are multiple A
|
||||
// records for a label, all the IP addresses are listed in
|
||||
// n.Records rather than having many resourceRecord's.
|
||||
// We must split them out into individual records, one for each value.
|
||||
for _, value := range n.Records {
|
||||
rc := &models.RecordConfig{
|
||||
TTL: n.TTL,
|
||||
Original: n,
|
||||
}
|
||||
rc.SetLabel(n.Subname, origin)
|
||||
switch rtype := n.Type; rtype {
|
||||
default: // "A", "AAAA", "CAA", "NS", "CNAME", "MX", "PTR", "SRV", "TXT"
|
||||
if err := rc.PopulateFromString(rtype, value, origin); err != nil {
|
||||
panic(fmt.Errorf("unparsable record received from deSEC: %w", err))
|
||||
}
|
||||
}
|
||||
rcs = append(rcs, rc)
|
||||
}
|
||||
|
||||
return rcs
|
||||
}
|
||||
|
||||
func recordsToNative(rcs []*models.RecordConfig, origin string) []resourceRecord {
|
||||
// Take a list of RecordConfig and return an equivalent list of resourceRecord.
|
||||
// deSEC requires one resourceRecord for each label:key tuple, therefore we
|
||||
// might collapse many RecordConfig into one resourceRecord.
|
||||
|
||||
var keys = map[models.RecordKey]*resourceRecord{}
|
||||
var zrs []resourceRecord
|
||||
|
||||
for _, r := range rcs {
|
||||
label := r.GetLabel()
|
||||
if label == "@" {
|
||||
label = ""
|
||||
}
|
||||
key := r.Key()
|
||||
|
||||
if zr, ok := keys[key]; !ok {
|
||||
// Allocate a new ZoneRecord:
|
||||
zr := resourceRecord{
|
||||
Type: r.Type,
|
||||
TTL: r.TTL,
|
||||
Subname: label,
|
||||
Records: []string{r.GetTargetCombined()},
|
||||
}
|
||||
zrs = append(zrs, zr)
|
||||
//keys[key] = &zr // This didn't work.
|
||||
keys[key] = &zrs[len(zrs)-1] // This does work. I don't know why.
|
||||
|
||||
} else {
|
||||
zr.Records = append(zr.Records, r.GetTargetCombined())
|
||||
|
||||
if r.TTL != zr.TTL {
|
||||
printer.Warnf("All TTLs for a rrset (%v) must be the same. Using smaller of %v and %v.\n", key, r.TTL, zr.TTL)
|
||||
if r.TTL < zr.TTL {
|
||||
zr.TTL = r.TTL
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return zrs
|
||||
}
|
218
providers/desec/desecProvider.go
Normal file
218
providers/desec/desecProvider.go
Normal file
|
@ -0,0 +1,218 @@
|
|||
package desec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v3/models"
|
||||
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
|
||||
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
|
||||
"github.com/StackExchange/dnscontrol/v3/providers"
|
||||
"github.com/miekg/dns/dnsutil"
|
||||
)
|
||||
|
||||
/*
|
||||
desec API DNS provider:
|
||||
Info required in `creds.json`:
|
||||
- auth-token
|
||||
*/
|
||||
|
||||
// NewDeSec creates the provider.
|
||||
func NewDeSec(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
|
||||
c := &api{}
|
||||
c.creds.token = m["auth-token"]
|
||||
if c.creds.token == "" {
|
||||
return nil, fmt.Errorf("missing deSEC auth-token")
|
||||
}
|
||||
|
||||
// Get a domain to validate authentication
|
||||
if err := c.fetchDomainList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var features = providers.DocumentationNotes{
|
||||
providers.DocDualHost: providers.Unimplemented(),
|
||||
providers.DocOfficiallySupported: providers.Cannot(),
|
||||
providers.DocCreateDomains: providers.Can(),
|
||||
providers.CanUseAlias: providers.Cannot(),
|
||||
providers.CanUseSRV: providers.Can(),
|
||||
providers.CanUseSSHFP: providers.Can(),
|
||||
providers.CanUseCAA: providers.Can(),
|
||||
providers.CanUseTLSA: providers.Can(),
|
||||
providers.CanUsePTR: providers.Unimplemented(),
|
||||
providers.CanGetZones: providers.Can(),
|
||||
providers.CanAutoDNSSEC: providers.Cannot(),
|
||||
}
|
||||
|
||||
var defaultNameServerNames = []string{
|
||||
"ns1.desec.io",
|
||||
"ns2.desec.org",
|
||||
}
|
||||
|
||||
func init() {
|
||||
providers.RegisterDomainServiceProviderType("DESEC", NewDeSec, features)
|
||||
}
|
||||
|
||||
// GetNameservers returns the nameservers for a domain.
|
||||
func (c *api) GetNameservers(domain string) ([]*models.Nameserver, error) {
|
||||
return models.ToNameservers(defaultNameServerNames)
|
||||
}
|
||||
|
||||
func (c *api) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
|
||||
existing, err := c.GetZoneRecords(dc.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
models.PostProcessRecords(existing)
|
||||
clean := PrepFoundRecords(existing)
|
||||
var min_ttl uint32
|
||||
if ttl, ok := c.domainIndex[dc.Name]; !ok {
|
||||
min_ttl = 3600
|
||||
} else {
|
||||
min_ttl = ttl
|
||||
}
|
||||
PrepDesiredRecords(dc, min_ttl)
|
||||
return c.GenerateDomainCorrections(dc, clean)
|
||||
}
|
||||
|
||||
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
|
||||
func (c *api) GetZoneRecords(domain string) (models.Records, error) {
|
||||
records, err := c.getRecords(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert them to DNScontrol's native format:
|
||||
existingRecords := []*models.RecordConfig{}
|
||||
for _, rr := range records {
|
||||
existingRecords = append(existingRecords, nativeToRecords(rr, domain)...)
|
||||
}
|
||||
return existingRecords, nil
|
||||
}
|
||||
|
||||
// EnsureDomainExists returns an error if domain doesn't exist.
|
||||
func (c *api) EnsureDomainExists(domain string) error {
|
||||
if err := c.fetchDomainList(); err != nil {
|
||||
return err
|
||||
}
|
||||
// domain already exists
|
||||
if _, ok := c.domainIndex[domain]; ok {
|
||||
return nil
|
||||
}
|
||||
return c.createDomain(domain)
|
||||
}
|
||||
|
||||
// PrepFoundRecords munges any records to make them compatible with
|
||||
// this provider. Usually this is a no-op.
|
||||
func PrepFoundRecords(recs models.Records) models.Records {
|
||||
// If there are records that need to be modified, removed, etc. we
|
||||
// do it here. Usually this is a no-op.
|
||||
return recs
|
||||
}
|
||||
|
||||
// PrepDesiredRecords munges any records to best suit this provider.
|
||||
func PrepDesiredRecords(dc *models.DomainConfig, min_ttl uint32) {
|
||||
// Sort through the dc.Records, eliminate any that can't be
|
||||
// supported; modify any that need adjustments to work with the
|
||||
// provider. We try to do minimal changes otherwise it gets
|
||||
// confusing.
|
||||
|
||||
dc.Punycode()
|
||||
recordsToKeep := make([]*models.RecordConfig, 0, len(dc.Records))
|
||||
for _, rec := range dc.Records {
|
||||
if rec.Type == "ALIAS" {
|
||||
// deSEC does not permit ALIAS records, just ignore it
|
||||
printer.Warnf("deSEC does not support alias records\n")
|
||||
continue
|
||||
}
|
||||
if rec.TTL < min_ttl {
|
||||
if rec.Type != "NS" {
|
||||
printer.Warnf("Please contact support@desec.io if you need ttls < %d. Setting ttl of %s type %s from %d to %d\n", min_ttl, rec.GetLabelFQDN(), rec.Type, rec.TTL, min_ttl)
|
||||
}
|
||||
rec.TTL = min_ttl
|
||||
}
|
||||
recordsToKeep = append(recordsToKeep, rec)
|
||||
}
|
||||
dc.Records = recordsToKeep
|
||||
}
|
||||
|
||||
// GenerateDomainCorrections takes the desired and existing records
|
||||
// and produces a Correction list. The correction list is simply
|
||||
// a list of functions to call to actually make the desired
|
||||
// correction, and a message to output to the user when the change is
|
||||
// made.
|
||||
func (client *api) GenerateDomainCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, error) {
|
||||
|
||||
var corrections = []*models.Correction{}
|
||||
|
||||
// diff existing vs. current.
|
||||
differ := diff.New(dc)
|
||||
keysToUpdate := differ.ChangedGroups(existing)
|
||||
if len(keysToUpdate) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
desiredRecords := dc.Records.GroupedByKey()
|
||||
var rrs []resourceRecord
|
||||
buf := &bytes.Buffer{}
|
||||
// For any key with an update, delete or replace those records.
|
||||
for label := range keysToUpdate {
|
||||
if _, ok := desiredRecords[label]; !ok {
|
||||
//we could not find this RecordKey in the desiredRecords
|
||||
//this means it must be deleted
|
||||
for i, msg := range keysToUpdate[label] {
|
||||
if i == 0 {
|
||||
rc := resourceRecord{}
|
||||
rc.Type = label.Type
|
||||
rc.Records = make([]string, 0) // empty array of records should delete this rrset
|
||||
rc.TTL = 3600
|
||||
shortname := dnsutil.TrimDomainName(label.NameFQDN, dc.Name)
|
||||
if shortname == "@" {
|
||||
shortname = ""
|
||||
}
|
||||
rc.Subname = shortname
|
||||
fmt.Fprintln(buf, msg)
|
||||
rrs = append(rrs, rc)
|
||||
} else {
|
||||
//just add the message
|
||||
fmt.Fprintln(buf, msg)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//it must be an update or create, both can be done with the same api call.
|
||||
ns := recordsToNative(desiredRecords[label], dc.Name)
|
||||
if len(ns) > 1 {
|
||||
panic("we got more than one resource record to create / modify")
|
||||
}
|
||||
for i, msg := range keysToUpdate[label] {
|
||||
if i == 0 {
|
||||
rrs = append(rrs, ns[0])
|
||||
fmt.Fprintln(buf, msg)
|
||||
} else {
|
||||
//noop just for printing the additional messages
|
||||
fmt.Fprintln(buf, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var msg string
|
||||
msg = fmt.Sprintf("Changes:\n%s", buf)
|
||||
corrections = append(corrections,
|
||||
&models.Correction{
|
||||
Msg: msg,
|
||||
F: func() error {
|
||||
rc := rrs
|
||||
err := client.upsertRR(rc, dc.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return corrections, nil
|
||||
}
|
217
providers/desec/protocol.go
Normal file
217
providers/desec/protocol.go
Normal file
|
@ -0,0 +1,217 @@
|
|||
package desec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
|
||||
)
|
||||
|
||||
const apiBase = "https://desec.io/api/v1"
|
||||
|
||||
// Api layer for desec
|
||||
type api struct {
|
||||
domainIndex map[string]uint32
|
||||
nameserversNames []string
|
||||
creds struct {
|
||||
tokenid string
|
||||
token string
|
||||
user string
|
||||
password string
|
||||
}
|
||||
}
|
||||
|
||||
type domainObject struct {
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Keys []dnssecKey `json:"keys,omitempty"`
|
||||
MinimumTTL uint32 `json:"minimum_ttl,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Published time.Time `json:"published,omitempty"`
|
||||
}
|
||||
|
||||
type resourceRecord struct {
|
||||
Subname string `json:"subname"`
|
||||
Records []string `json:"records"`
|
||||
TTL uint32 `json:"ttl,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"-"`
|
||||
}
|
||||
|
||||
type rrResponse struct {
|
||||
resourceRecord
|
||||
Created time.Time `json:"created"`
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type dnssecKey struct {
|
||||
Dnskey string `json:"dnskey"`
|
||||
Ds []string `json:"ds"`
|
||||
Flags int `json:"flags"`
|
||||
Keytype string `json:"keytype"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func (c *api) fetchDomainList() error {
|
||||
c.domainIndex = map[string]uint32{}
|
||||
var dr []domainObject
|
||||
endpoint := "/domains/"
|
||||
var bodyString, err = c.get(endpoint, "GET")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Error fetching domain list from deSEC: %s", err)
|
||||
}
|
||||
err = json.Unmarshal(bodyString, &dr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, domain := range dr {
|
||||
//We store the min ttl in the domain index
|
||||
//This will be used for validation and auto correction
|
||||
c.domainIndex[domain.Name] = domain.MinimumTTL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *api) getRecords(domain string) ([]resourceRecord, error) {
|
||||
endpoint := "/domains/%s/rrsets/"
|
||||
var rrs []rrResponse
|
||||
var rrs_new []resourceRecord
|
||||
var bodyString, err = c.get(fmt.Sprintf(endpoint, domain), "GET")
|
||||
if err != nil {
|
||||
return rrs_new, fmt.Errorf("Error fetching records from deSEC for domain %s: %s", domain, err)
|
||||
}
|
||||
err = json.Unmarshal(bodyString, &rrs)
|
||||
if err != nil {
|
||||
return rrs_new, err
|
||||
}
|
||||
// deSEC returns round robin records as array but dnsconfig expects single entries for each record
|
||||
// we will create one object per record except of TXT records which are handled as array of string by dnscontrol aswell.
|
||||
for i := range rrs {
|
||||
tmp := resourceRecord{
|
||||
TTL: rrs[i].TTL,
|
||||
Type: rrs[i].Type,
|
||||
Subname: rrs[i].Subname,
|
||||
Records: rrs[i].Records,
|
||||
}
|
||||
rrs_new = append(rrs_new, tmp)
|
||||
}
|
||||
return rrs_new, nil
|
||||
}
|
||||
|
||||
func (c *api) createDomain(domain string) error {
|
||||
endpoint := "/domains/"
|
||||
pl := domainObject{Name: domain}
|
||||
byt, _ := json.Marshal(pl)
|
||||
var resp []byte
|
||||
var err error
|
||||
if resp, err = c.post(endpoint, "POST", byt); err != nil {
|
||||
return fmt.Errorf("Error create domain deSEC: %v", err)
|
||||
}
|
||||
dm := domainObject{}
|
||||
err = json.Unmarshal(resp, &dm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printer.Printf("If you want to use DNSSec please add the DS record at your registrar using one of the keys:\n")
|
||||
printer.Printf("%+q", dm.Keys)
|
||||
return nil
|
||||
}
|
||||
|
||||
//upsertRR will create or override the RRSet with the provided resource record.
|
||||
func (c *api) upsertRR(rr []resourceRecord, domain string) error {
|
||||
endpoint := fmt.Sprintf("/domains/%s/rrsets/", domain)
|
||||
byt, _ := json.Marshal(rr)
|
||||
if _, err := c.post(endpoint, "PUT", byt); err != nil {
|
||||
return fmt.Errorf("Error create rrset deSEC: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *api) deleteRR(domain, shortname, t string) error {
|
||||
endpoint := fmt.Sprintf("/domains/%s/rrsets/%s/%s/", domain, shortname, t)
|
||||
if _, err := c.get(endpoint, "DELETE"); err != nil {
|
||||
return fmt.Errorf("Error delete rrset deSEC: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *api) get(endpoint, method string) ([]byte, error) {
|
||||
retrycnt := 0
|
||||
retry:
|
||||
client := &http.Client{}
|
||||
req, _ := http.NewRequest(method, apiBase+endpoint, nil)
|
||||
q := req.URL.Query()
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", c.creds.token))
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
|
||||
bodyString, _ := ioutil.ReadAll(resp.Body)
|
||||
// Got error from API ?
|
||||
if resp.StatusCode > 299 {
|
||||
if resp.StatusCode == 429 && retrycnt < 5 {
|
||||
retrycnt++
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
goto retry
|
||||
}
|
||||
var errResp errorResponse
|
||||
err = json.Unmarshal(bodyString, &errResp)
|
||||
if err == nil {
|
||||
return bodyString, fmt.Errorf("%s", errResp.Detail)
|
||||
}
|
||||
return bodyString, fmt.Errorf("http status %d %s, the api does not provide more information", resp.StatusCode, resp.Status)
|
||||
}
|
||||
return bodyString, nil
|
||||
}
|
||||
|
||||
func (c *api) post(endpoint, method string, payload []byte) ([]byte, error) {
|
||||
retrycnt := 0
|
||||
retry:
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest(method, apiBase+endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if endpoint != "/auth/login/" {
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", c.creds.token))
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
|
||||
bodyString, _ := ioutil.ReadAll(resp.Body)
|
||||
|
||||
// Got error from API ?
|
||||
if resp.StatusCode > 299 {
|
||||
if resp.StatusCode == 429 && retrycnt < 5 {
|
||||
retrycnt++
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
goto retry
|
||||
}
|
||||
var errResp errorResponse
|
||||
err = json.Unmarshal(bodyString, &errResp)
|
||||
if err == nil {
|
||||
return bodyString, fmt.Errorf("http status %d %s details: %s", resp.StatusCode, resp.Status, errResp.Detail)
|
||||
}
|
||||
return bodyString, fmt.Errorf("http status %d %s, the api does not provide more information", resp.StatusCode, resp.Status)
|
||||
}
|
||||
//time.Sleep(334 * time.Millisecond)
|
||||
return bodyString, nil
|
||||
}
|
Loading…
Reference in a new issue