diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 43cbd7fa4be..36fa13da007 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -1745,22 +1745,21 @@ func displayDNSHelp(w io.Writer, name string) error { // generated from: providers/dns/nicru/nicru.toml ew.writeln(`Configuration for RU CENTER.`) ew.writeln(`Code: 'nicru'`) - ew.writeln(`Since: 'v4.11.0'`) + ew.writeln(`Since: 'v4.12.0'`) ew.writeln() ew.writeln(`Credentials:`) - ew.writeln(` - "NIC_RU_PASSWORD": Password for account in RU CENTER`) - ew.writeln(` - "NIC_RU_SECRET": Secret for application in DNS-hosting RU CENTER`) - ew.writeln(` - "NIC_RU_SERVICE_ID": Service ID for application in DNS-hosting RU CENTER`) - ew.writeln(` - "NIC_RU_SERVICE_NAME": Service Name for DNS-hosting RU CENTER`) - ew.writeln(` - "NIC_RU_USER": Agreement for account in RU CENTER`) + ew.writeln(` - "NICRU_PASSWORD": Password for account in RU CENTER`) + ew.writeln(` - "NICRU_SECRET": Secret for application in DNS-hosting RU CENTER`) + ew.writeln(` - "NICRU_SERVICE_ID": Service ID for application in DNS-hosting RU CENTER`) + ew.writeln(` - "NICRU_SERVICE_NAME": Service Name for DNS-hosting RU CENTER`) + ew.writeln(` - "NICRU_USER": Agreement for account in RU CENTER`) ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "NIC_RU_HTTP_TIMEOUT": API request timeout`) - ew.writeln(` - "NIC_RU_POLLING_INTERVAL": Time between DNS propagation check`) - ew.writeln(` - "NIC_RU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) - ew.writeln(` - "NIC_RU_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "NICRU_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "NICRU_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "NICRU_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/nicru`) diff --git a/docs/content/dns/zz_gen_nicru.md b/docs/content/dns/zz_gen_nicru.md new file mode 100644 index 00000000000..3ef3722309d --- /dev/null +++ b/docs/content/dns/zz_gen_nicru.md @@ -0,0 +1,85 @@ +--- +title: "RU CENTER" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: nicru +dnsprovider: + since: "v4.12.0" + code: "nicru" + url: "https://nic.ru/" +--- + + + + + + +Configuration for [RU CENTER](https://nic.ru/). + + + + +- Code: `nicru` +- Since: v4.12.0 + + +Here is an example bash command using the RU CENTER provider: + +```bash +NICRU_USER="" \ +NICRU_PASSWORD="" \ +NICRU_SERVICE_ID="" \ +NICRU_SECRET="" \ +NICRU_SERVICE_NAME="" \ +./lego --dns nicru --domains "*.example.com" --email you@example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NICRU_PASSWORD` | Password for account in RU CENTER | +| `NICRU_SECRET` | Secret for application in DNS-hosting RU CENTER | +| `NICRU_SERVICE_ID` | Service ID for application in DNS-hosting RU CENTER | +| `NICRU_SERVICE_NAME` | Service Name for DNS-hosting RU CENTER | +| `NICRU_USER` | Agreement for account in RU CENTER | + +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 | +|--------------------------------|-------------| +| `NICRU_POLLING_INTERVAL` | Time between DNS propagation check | +| `NICRU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `NICRU_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" >}}). + +## Credential inforamtion + +You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list + +| ENV Variable | Parameter from page | Example | +|---------------------|--------------------------------|-------------------| +| NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | +| NICRU_PASSWORD | Password account | | +| NICRU_SERVICE_ID | Application ID | hex-based, len 32 | +| NICRU_SECRET | Identity endpoint | string len 91 | +| NICRU_SERVICE_NAME | Service name in DNS-hosting | DPNNNNNNNNNN | + + + +## More information + +- [API documentation](https://www.nic.ru/help/api-dns-hostinga_3643.html) + + + + diff --git a/providers/dns/nicru/internal/client.go b/providers/dns/nicru/internal/client.go index ee4fb13e9c7..613c060d203 100644 --- a/providers/dns/nicru/internal/client.go +++ b/providers/dns/nicru/internal/client.go @@ -6,292 +6,186 @@ import ( "encoding/xml" "errors" "fmt" - "golang.org/x/oauth2" + "io" "net/http" - "strconv" + "net/url" + "time" ) const ( - BaseURL = `https://api.nic.ru` - TokenURL = BaseURL + `/oauth/token` - GetZonesUrlPattern = BaseURL + `/dns-master/services/%s/zones` - GetRecordsUrlPattern = BaseURL + `/dns-master/services/%s/zones/%s/records` - DeleteRecordsUrlPattern = BaseURL + `/dns-master/services/%s/zones/%s/records/%d` - AddRecordsUrlPattern = BaseURL + `/dns-master/services/%s/zones/%s/records` - CommitUrlPattern = BaseURL + `/dns-master/services/%s/zones/%s/commit` - SuccessStatus = `success` - OAuth2Scope = `.+:/dns-master/.+` + apiBaseURL = "https://api.nic.ru/dns-master" + tokenURL = "https://api.nic.ru/oauth/token" ) -// Provider facilitates DNS record manipulation with NIC.ru. -type Provider struct { - OAuth2ClientID string `json:"oauth2_client_id"` - OAuth2SecretID string `json:"oauth2_secret_id"` - Username string `json:"username"` - Password string `json:"password"` - ServiceName string `json:"service_name"` -} +const successStatus = "success" -type Client struct { - client *http.Client - provider *Provider - token string +// Trimmer trim all XML fields. +type Trimmer struct { + decoder *xml.Decoder } -func NewClient(provider *Provider) (*Client, error) { - client := Client{provider: provider} - err := client.validateAuthOptions() - if err != nil { - return nil, err +func (tr Trimmer) Token() (xml.Token, error) { + t, err := tr.decoder.Token() + if cd, ok := t.(xml.CharData); ok { + t = xml.CharData(bytes.TrimSpace(cd)) } - return &client, nil + return t, err } -func (client *Client) GetOauth2Client() error { - ctx := context.TODO() - - oauth2Config := oauth2.Config{ - ClientID: client.provider.OAuth2ClientID, - ClientSecret: client.provider.OAuth2SecretID, - Endpoint: oauth2.Endpoint{ - TokenURL: TokenURL, - AuthStyle: oauth2.AuthStyleInParams, - }, - Scopes: []string{OAuth2Scope}, - } - - oauth2Token, err := oauth2Config.PasswordCredentialsToken(ctx, client.provider.Username, client.provider.Password) - if err != nil { - return fmt.Errorf("nicru: %s", err.Error()) - } +type Client struct { + serviceName string - client.client = oauth2Config.Client(ctx, oauth2Token) - return nil + baseURL *url.URL + httpClient *http.Client } -func (client *Client) Do(r *http.Request) (*http.Response, error) { - if client.client == nil { - err := client.GetOauth2Client() - if err != nil { - return nil, err - } +func NewClient(httpClient *http.Client, serviceName string) (*Client, error) { + if serviceName == "" { + return nil, errors.New("service name is empty") } - return client.client.Do(r) -} -func (client *Client) GetZones() ([]*Zone, error) { - request, err := http.NewRequest(http.MethodGet, fmt.Sprintf(GetZonesUrlPattern, client.provider.ServiceName), nil) - if err != nil { - return nil, err - } - response, err := client.Do(request) - if err != nil { - return nil, err + if httpClient == nil { + httpClient = &http.Client{Timeout: 5 * time.Second} } - buf := bytes.NewBuffer(nil) - if _, err := buf.ReadFrom(response.Body); err != nil { - return nil, err - } + baseURL, _ := url.Parse(apiBaseURL) - apiResponse := &Response{} - if err := xml.NewDecoder(buf).Decode(&apiResponse); err != nil { - return nil, err - } else { - var zones []*Zone - for _, zone := range apiResponse.Data.Zone { - zones = append(zones, zone) - } - return zones, nil - } + return &Client{ + serviceName: serviceName, + baseURL: baseURL, + httpClient: httpClient, + }, nil } -func (client *Client) GetRecords(fqdn string) ([]*RR, error) { - request, err := http.NewRequest( - http.MethodGet, - fmt.Sprintf(GetRecordsUrlPattern, client.provider.ServiceName, fqdn), - nil) +func (c *Client) GetZones(ctx context.Context) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("services", c.serviceName, "zones") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, err } - response, err := client.Do(request) + + apiResponse, err := c.do(req) if err != nil { return nil, err } - buf := bytes.NewBuffer(nil) - if _, err := buf.ReadFrom(response.Body); err != nil { - return nil, err + if apiResponse.Data == nil { + return nil, nil } - apiResponse := &Response{} - if err := xml.NewDecoder(buf).Decode(&apiResponse); err != nil { - return nil, err - } else { - var records []*RR - for _, zone := range apiResponse.Data.Zone { - records = append(records, zone.Rr...) - } - return records, nil - } + return apiResponse.Data.Zone, nil } -func (client *Client) add(zoneName string, request *Request) (*Response, error) { +func (c *Client) GetRecords(ctx context.Context, zoneName string) ([]RR, error) { + endpoint := c.baseURL.JoinPath("services", c.serviceName, "zones", zoneName, "records") - buf := bytes.NewBuffer(nil) - if err := xml.NewEncoder(buf).Encode(request); err != nil { - return nil, err - } - - url := fmt.Sprintf(AddRecordsUrlPattern, client.provider.ServiceName, zoneName) - - req, err := http.NewRequest(http.MethodPut, url, buf) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) if err != nil { return nil, err } - response, err := client.Do(req) + apiResponse, err := c.do(req) if err != nil { return nil, err } - buf = bytes.NewBuffer(nil) - if _, err := buf.ReadFrom(response.Body); err != nil { - return nil, err + if apiResponse.Data == nil { + return nil, nil } - apiResponse := &Response{} - if err := xml.NewDecoder(buf).Decode(&apiResponse); err != nil { - return nil, err + var records []RR + for _, zone := range apiResponse.Data.Zone { + records = append(records, zone.RR...) } - if apiResponse.Status != SuccessStatus { - return nil, fmt.Errorf(describeError(apiResponse.Errors.Error)) - } else { - return apiResponse, nil - } + return records, nil } -func (client *Client) deleteRecord(zoneName string, id int) (*Response, error) { - url := fmt.Sprintf(DeleteRecordsUrlPattern, client.provider.ServiceName, zoneName, id) - req, err := http.NewRequest(http.MethodDelete, url, nil) +func (c *Client) DeleteRecord(ctx context.Context, zoneName string, id string) error { + endpoint := c.baseURL.JoinPath("services", c.serviceName, "zones", zoneName, "records", id) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint.String(), nil) if err != nil { - return nil, err + return err } - response, err := client.Do(req) + + _, err = c.do(req) if err != nil { - return nil, err - } - apiResponse := Response{} - if err := xml.NewDecoder(response.Body).Decode(&apiResponse); err != nil { - return nil, err - } - if apiResponse.Status != SuccessStatus { - return nil, err - } else { - return &apiResponse, nil + return err } + + return nil } -func (client *Client) GetTXTRecords(fqdn string) ([]*Txt, error) { - records, err := client.GetRecords(fqdn) +func (c *Client) CommitZone(ctx context.Context, zoneName string) error { + endpoint := c.baseURL.JoinPath("services", c.serviceName, "zones", zoneName, "commit") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), nil) if err != nil { - return nil, err + return err } - txt := make([]*Txt, 0) - for _, record := range records { - if record.Txt != nil { - txt = append(txt, record.Txt) - } + _, err = c.do(req) + if err != nil { + return err } - return txt, nil + return nil } -func (client *Client) AddTxtRecord(zoneName string, name string, content string, ttl int) (*Response, error) { - request := &Request{ - RrList: &RrList{ - Rr: []*RR{}, - }, - } - request.RrList.Rr = append(request.RrList.Rr, &RR{ - Name: name, - Ttl: strconv.Itoa(ttl), - Type: `TXT`, - Txt: &Txt{ - String: content, - }, - }) - - return client.add(zoneName, request) -} +func (c *Client) AddRecords(ctx context.Context, zoneName string, rrs []RR) ([]Zone, error) { + endpoint := c.baseURL.JoinPath("services", c.serviceName, "zones", zoneName, "records") -func (client *Client) DeleteRecord(zoneName string, id int) (*Response, error) { - url := fmt.Sprintf(DeleteRecordsUrlPattern, client.provider.ServiceName, zoneName, id) - req, err := http.NewRequest(http.MethodDelete, url, nil) - if err != nil { + payload := &Request{RRList: &RRList{RR: rrs}} + + body := &bytes.Buffer{} + if err := xml.NewEncoder(body).Encode(payload); err != nil { return nil, err } - response, err := client.Do(req) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint.String(), body) if err != nil { return nil, err } - apiResponse := Response{} - if err := xml.NewDecoder(response.Body).Decode(&apiResponse); err != nil { + + apiResponse, err := c.do(req) + if err != nil { return nil, err } - if apiResponse.Status != SuccessStatus { - return nil, err - } else { - return &apiResponse, nil + + if apiResponse.Data == nil { + return nil, nil } + + return apiResponse.Data.Zone, nil } -func (client *Client) CommitZone(zoneName string) (*Response, error) { - url := fmt.Sprintf(CommitUrlPattern, client.provider.ServiceName, zoneName) - request, err := http.NewRequest(http.MethodPost, url, nil) - if err != nil { - return nil, err - } - response, err := client.Do(request) +func (c *Client) do(req *http.Request) (*Response, error) { + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } - apiResponse := Response{} - if err := xml.NewDecoder(response.Body).Decode(&apiResponse); err != nil { - return nil, err - } - if apiResponse.Status != SuccessStatus { - return nil, err - } else { - return &apiResponse, nil - } -} - -func (client *Client) validateAuthOptions() error { - msg := " is missing in credentials information" + defer func() { _ = resp.Body.Close() }() - if client.provider.ServiceName == "" { - return errors.New("service name" + msg) - } + apiResponse := &Response{} - if client.provider.Username == "" { - return errors.New("username" + msg) + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err } - if client.provider.Password == "" { - return errors.New("password" + msg) - } + decoder := xml.NewTokenDecoder(Trimmer{decoder: xml.NewDecoder(bytes.NewReader(raw))}) - if client.provider.OAuth2ClientID == "" { - return errors.New("serviceId" + msg) + err = decoder.Decode(apiResponse) + if err != nil { + return nil, fmt.Errorf("[status code=%d] %s", resp.StatusCode, string(raw)) } - if client.provider.OAuth2SecretID == "" { - return errors.New("secret" + msg) + if apiResponse.Status != successStatus { + return nil, fmt.Errorf("[status code=%d] %s: %w", resp.StatusCode, apiResponse.Status, apiResponse.Errors.Error) } - return nil + return apiResponse, nil } diff --git a/providers/dns/nicru/internal/client_test.go b/providers/dns/nicru/internal/client_test.go new file mode 100644 index 00000000000..1239bf904f4 --- /dev/null +++ b/providers/dns/nicru/internal/client_test.go @@ -0,0 +1,306 @@ +package internal + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTest(t *testing.T, pattern string, handler http.HandlerFunc) *Client { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc(pattern, handler) + + client, err := NewClient(server.Client(), "test") + require.NoError(t, err) + + client.baseURL, _ = url.Parse(server.URL) + + return client +} + +func writeFixtures(method string, filename string, status int) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + if req.Method != method { + http.Error(rw, fmt.Sprintf("unsupported method: %s", req.Method), http.StatusMethodNotAllowed) + return + } + + 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(status) + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func TestClient_GetZones(t *testing.T) { + client := setupTest(t, "/services/test/zones", + writeFixtures(http.MethodGet, "zones_GET.xml", http.StatusOK)) + + zones, err := client.GetZones(context.Background()) + require.NoError(t, err) + + expected := []Zone{ + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227645", + IDNName: "тест.рф", + Name: "xn—e1aybc.xn--p1ai", + Payer: "123/NIC-REG", + Service: "myservice", + }, + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227642", + IDNName: "example.ru", + Name: "example.ru", + Payer: "123/NIC-REG", + Service: "myservice", + }, + { + Admin: "123/NIC-REG", + Enable: "true", + HasChanges: "false", + HasPrimary: "true", + ID: "227643", + IDNName: "test.su", + Name: "test.su", + Payer: "123/NIC-REG", + Service: "myservice", + }, + } + + assert.Equal(t, expected, zones) +} + +func TestClient_GetZones_error(t *testing.T) { + client := setupTest(t, "/services/test/zones", + writeFixtures(http.MethodGet, "errors.xml", http.StatusOK)) + + _, err := client.GetZones(context.Background()) + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_GetRecords(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./records", + writeFixtures(http.MethodGet, "records_GET.xml", http.StatusOK)) + + records, err := client.GetRecords(context.Background(), "example.com.") + require.NoError(t, err) + + expected := []RR{ + { + ID: "210074", + Name: "@", + IDNName: "@", + TTL: "", + Type: "SOA", + SOA: &SOA{ + MName: &MName{ + Name: "ns3-l2.nic.ru.", + IDNName: "ns3-l2.nic.ru.", + }, + RName: &RName{ + Name: "dns.nic.ru.", + IDNName: "dns.nic.ru.", + }, + Serial: "2011112002", + Refresh: "1440", + Retry: "3600", + Expire: "2592000", + Minimum: "600", + }, + }, + { + ID: "210075", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns3-l2.nic.ru.", + IDNName: "ns3- l2.nic.ru.", + }, + }, + { + ID: "210076", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns4-l2.nic.ru.", + IDNName: "ns4-l2.nic.ru.", + }, + }, + { + ID: "210077", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns8-l2.nic.ru.", + IDNName: "ns8- l2.nic.ru.", + }, + }, + } + + assert.Equal(t, expected, records) +} + +func TestClient_GetRecords_error(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./records", + writeFixtures(http.MethodGet, "errors.xml", http.StatusOK)) + + _, err := client.GetRecords(context.Background(), "example.com.") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_AddRecord(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./records", + writeFixtures(http.MethodPut, "records_PUT.xml", http.StatusOK)) + + rrs := []RR{ + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns4-l2.nic.ru."}, + }, + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns8-l2.nic.ru."}, + }, + } + + response, err := client.AddRecords(context.Background(), "example.com.", rrs) + require.NoError(t, err) + + expected := []Zone{ + { + Admin: "123/NIC-REG", + HasChanges: "true", + ID: "228095", + IDNName: "test.ru", + Name: "test.ru", + Service: "testservice", + RR: []RR{ + { + ID: "210076", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns4-l2.nic.ru.", + IDNName: "ns4-l2.nic.ru.", + }, + }, + { + ID: "210077", + Name: "@", + IDNName: "@", + Type: "NS", + NS: &NS{ + Name: "ns8-l2.nic.ru.", + IDNName: "ns8-l2.nic.ru.", + }, + }, + }, + }, + } + + assert.Equal(t, expected, response) +} + +func TestClient_AddRecord_error(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./records", + writeFixtures(http.MethodPut, "errors.xml", http.StatusOK)) + + rrs := []RR{ + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns4-l2.nic.ru."}, + }, + { + Name: "@", + Type: "NS", + NS: &NS{Name: "ns8-l2.nic.ru."}, + }, + } + + _, err := client.AddRecords(context.Background(), "example.com.", rrs) + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_DeleteRecord(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./records/123", + writeFixtures(http.MethodDelete, "record_DELETE.xml", http.StatusUnauthorized)) + + err := client.DeleteRecord(context.Background(), "example.com.", "123") + require.NoError(t, err) +} + +func TestClient_DeleteRecord_error(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./records/123", + writeFixtures(http.MethodDelete, "errors.xml", http.StatusUnauthorized)) + + err := client.DeleteRecord(context.Background(), "example.com.", "123") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} + +func TestClient_CommitZone(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./commit", writeFixtures(http.MethodPost, "commit_POST.xml", http.StatusOK)) + + err := client.CommitZone(context.Background(), "example.com.") + require.NoError(t, err) +} + +func TestClient_CommitZone_error(t *testing.T) { + client := setupTest(t, "/services/test/zones/example.com./commit", writeFixtures(http.MethodPost, "errors.xml", http.StatusOK)) + + err := client.CommitZone(context.Background(), "example.com.") + require.ErrorIs(t, err, Error{ + Text: "Access token expired or not found", + Code: "4097", + }) +} diff --git a/providers/dns/nicru/internal/fixtures/commit_POST.xml b/providers/dns/nicru/internal/fixtures/commit_POST.xml new file mode 100644 index 00000000000..530a22d163c --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/commit_POST.xml @@ -0,0 +1,4 @@ + + + success + diff --git a/providers/dns/nicru/internal/fixtures/errors.xml b/providers/dns/nicru/internal/fixtures/errors.xml new file mode 100644 index 00000000000..961b9a49581 --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/errors.xml @@ -0,0 +1,7 @@ + + + fail + + Access token expired or not found + + diff --git a/providers/dns/nicru/internal/fixtures/record_DELETE.xml b/providers/dns/nicru/internal/fixtures/record_DELETE.xml new file mode 100644 index 00000000000..530a22d163c --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/record_DELETE.xml @@ -0,0 +1,4 @@ + + + success + diff --git a/providers/dns/nicru/internal/fixtures/records_GET.xml b/providers/dns/nicru/internal/fixtures/records_GET.xml new file mode 100644 index 00000000000..a9df348f9df --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/records_GET.xml @@ -0,0 +1,55 @@ + + + success + + + + @ + @ + SOA + + + ns3-l2.nic.ru. + ns3-l2.nic.ru. + + + dns.nic.ru. + dns.nic.ru. + + 2011112002 + 1440 + 3600 + 2592000 + 600 + + + + @ + @ + NS + + ns3-l2.nic.ru. + ns3- l2.nic.ru. + + + + @ + @ + NS + + ns4-l2.nic.ru. + ns4-l2.nic.ru. + + + + @ + @ + NS + + ns8-l2.nic.ru. + ns8- l2.nic.ru. + + + + + diff --git a/providers/dns/nicru/internal/fixtures/records_PUT.xml b/providers/dns/nicru/internal/fixtures/records_PUT.xml new file mode 100644 index 00000000000..a3417a8f35f --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/records_PUT.xml @@ -0,0 +1,10 @@ + + + success + + + @@NSns4-l2.nic.ru.ns4-l2.nic.ru. + @@NSns8-l2.nic.ru.ns8-l2.nic.ru. + + + diff --git a/providers/dns/nicru/internal/fixtures/zones_GET.xml b/providers/dns/nicru/internal/fixtures/zones_GET.xml new file mode 100644 index 00000000000..efa2da9a24b --- /dev/null +++ b/providers/dns/nicru/internal/fixtures/zones_GET.xml @@ -0,0 +1,12 @@ + + + success + + + + + + diff --git a/providers/dns/nicru/internal/identity.go b/providers/dns/nicru/internal/identity.go new file mode 100644 index 00000000000..b4281adbeb2 --- /dev/null +++ b/providers/dns/nicru/internal/identity.go @@ -0,0 +1,64 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "net/http" + + "golang.org/x/oauth2" +) + +// OauthConfiguration credentials. +type OauthConfiguration struct { + OAuth2ClientID string + OAuth2SecretID string + Username string + Password string +} + +func (config *OauthConfiguration) Validate() error { + msg := " is missing in credentials information" + + if config.Username == "" { + return errors.New("username" + msg) + } + + if config.Password == "" { + return errors.New("password" + msg) + } + + if config.OAuth2ClientID == "" { + return errors.New("serviceID" + msg) + } + + if config.OAuth2SecretID == "" { + return errors.New("secret" + msg) + } + + return nil +} + +func NewOauthClient(ctx context.Context, config *OauthConfiguration) (*http.Client, error) { + err := config.Validate() + if err != nil { + return nil, err + } + + oauth2Config := oauth2.Config{ + ClientID: config.OAuth2ClientID, + ClientSecret: config.OAuth2SecretID, + Endpoint: oauth2.Endpoint{ + TokenURL: tokenURL, + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{".+:/dns-master/.+"}, + } + + oauth2Token, err := oauth2Config.PasswordCredentialsToken(ctx, config.Username, config.Password) + if err != nil { + return nil, fmt.Errorf("failed to create oauth2 token: %w", err) + } + + return oauth2Config.Client(ctx, oauth2Token), nil +} diff --git a/providers/dns/nicru/internal/model.go b/providers/dns/nicru/internal/model.go deleted file mode 100644 index a95e40ecf4d..00000000000 --- a/providers/dns/nicru/internal/model.go +++ /dev/null @@ -1,219 +0,0 @@ -package internal - -import ( - "encoding/xml" - "fmt" -) - -type Request struct { - XMLName xml.Name `xml:"request" json:"xml_name,omitempty"` - Text string `xml:",chardata" json:"text,omitempty"` - RrList *RrList `xml:"rr-list" json:"rr_list,omitempty"` -} - -type RrList struct { - Text string `xml:",chardata" json:"text,omitempty"` - Rr []*RR `xml:"rr" json:"rr,omitempty"` -} - -type RR struct { - Text string `xml:",chardata" json:"text,omitempty"` - ID string `xml:"id,attr,omitempty" json:"id,omitempty"` - Name string `xml:"name" json:"name,omitempty"` - IdnName string `xml:"idn-name,omitempty" json:"idn_name,omitempty"` - Ttl string `xml:"ttl" json:"ttl,omitempty"` - Type string `xml:"type" json:"type,omitempty"` - Soa *Soa `xml:"soa" xml:"soa,omitempty"` - A *Address `xml:"a" json:"a,omitempty"` - AAAA *Address `xml:"aaaa" json:"aaaa,omitempty"` - Cname *Cname `xml:"cname" json:"cname,omitempty"` - Ns *Ns `xml:"ns" json:"ns,omitempty"` - Mx *Mx `xml:"mx" json:"mx,omitempty"` - Srv *Srv `xml:"srv" json:"srv,omitempty"` - Ptr *Ptr `xml:"ptr" json:"ptr,omitempty"` - Txt *Txt `xml:"txt" json:"txt,omitempty"` - Dname *Dname `xml:"dname" json:"dname,omitempty"` - Hinfo *Hinfo `xml:"hinfo" json:"hinfo,omitempty"` - Naptr *Naptr `xml:"naptr" json:"naptr,omitempty"` - Rp *Rp `xml:"rp" json:"rp,omitempty"` -} - -type Address string - -func (address *Address) String() string { - return string(*address) -} - -type Service struct { - Text string `xml:",chardata" json:"text,omitempty"` - Admin string `xml:"admin,attr" json:"admin,omitempty"` - DomainsLimit string `xml:"domains-limit,attr" json:"domains_limit,omitempty"` - DomainsNum string `xml:"domains-num,attr" json:"domains_num,omitempty"` - Enable string `xml:"enable,attr" json:"enable,omitempty"` - HasPrimary string `xml:"has-primary,attr" json:"has_primary,omitempty"` - Name string `xml:"name,attr" json:"name,omitempty"` - Payer string `xml:"payer,attr" json:"payer,omitempty"` - Tariff string `xml:"tariff,attr" json:"tariff,omitempty"` - RrLimit string `xml:"rr-limit,attr" json:"rr_limit,omitempty"` - RrNum string `xml:"rr-num,attr" json:"rr_num,omitempty"` -} - -type Soa struct { - Text string `xml:",chardata" json:"text,omitempty"` - Mname *Mname `xml:"mname" json:"mname,omitempty"` - Rname *Rname `xml:"rname" json:"rname,omitempty"` - Serial string `xml:"serial" json:"serial,omitempty"` - Refresh string `xml:"refresh" json:"refresh,omitempty"` - Retry string `xml:"retry" json:"retry,omitempty"` - Expire string `xml:"expire" json:"expire,omitempty"` - Minimum string `xml:"minimum" json:"minimum,omitempty"` -} - -type Mname struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` - IdnName string `xml:"idn-name,omitempty" json:"idn_name,omitempty"` -} - -type Rname struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` - IdnName string `xml:"idn-name,omitempty" json:"idn_name,omitempty"` -} - -type Ns struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` - IdnName string `xml:"idn-name,omitempty" json:"idn_name,omitempty"` -} - -type Mx struct { - Text string `xml:",chardata" json:"text,omitempty"` - Preference string `xml:"preference" json:"preference,omitempty"` - Exchange *Exchange `xml:"exchange" json:"exchange,omitempty"` -} - -type Exchange struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type Srv struct { - Text string `xml:",chardata" json:"text,omitempty"` - Priority string `xml:"priority" json:"priority,omitempty"` - Weight string `xml:"weight" json:"weight,omitempty"` - Port string `xml:"port" json:"port,omitempty"` - Target *Target `xml:"target" json:"target,omitempty"` -} - -type Target struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type Ptr struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type Hinfo struct { - Text string `xml:",chardata" json:"text,omitempty"` - Hardware string `xml:"hardware" json:"hardware,omitempty"` - Os string `xml:"os" json:"os,omitempty"` -} - -type Naptr struct { - Text string `xml:",chardata" json:"text,omitempty"` - Order string `xml:"order" json:"order,omitempty"` - Preference string `xml:"preference" json:"preference,omitempty"` - Flags string `xml:"flags" json:"flags,omitempty"` - Service string `xml:"service" json:"service,omitempty"` - Regexp string `xml:"regexp" json:"regexp,omitempty"` - Replacement *Replacement `xml:"replacement" json:"replacement,omitempty"` -} - -type Replacement struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type Rp struct { - Text string `xml:",chardata" json:"text,omitempty"` - MboxDname *MboxDname `xml:"mbox-dname" json:"mbox_dname,omitempty"` - TxtDname *TxtDname `xml:"txt-dname" json:"txt_dname,omitempty"` -} - -type MboxDname struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type TxtDname struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type Cname struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` - IdnName string `xml:"idn-name,omitempty" json:"idn_name,omitempty"` -} - -type Dname struct { - Text string `xml:",chardata" json:"text,omitempty"` - Name string `xml:"name" json:"name,omitempty"` -} - -type Txt struct { - Text string `xml:",chardata" json:"text,omitempty"` - String string `xml:"string" json:"string,omitempty"` -} - -type Zone struct { - Text string `xml:",chardata" json:"text,omitempty"` - Admin string `xml:"admin,attr" json:"admin,omitempty"` - Enable string `xml:"enable,attr" json:"enable,omitempty"` - HasChanges string `xml:"has-changes,attr" json:"has_changes,omitempty"` - HasPrimary string `xml:"has-primary,attr" json:"has_primary,omitempty"` - ID string `xml:"id,attr" json:"id,omitempty"` - IdnName string `xml:"idn-name,attr" json:"idn_name,omitempty"` - Name string `xml:"name,attr" json:"name,omitempty"` - Payer string `xml:"payer,attr" json:"payer,omitempty"` - Service string `xml:"service,attr" json:"service,omitempty"` - Rr []*RR `xml:"rr" json:"rr,omitempty"` -} - -type Revision struct { - Text string `xml:",chardata" json:"text,omitempty"` - Date string `xml:"date,attr" json:"date,omitempty"` - Ip string `xml:"ip,attr" json:"ip,omitempty"` - Number string `xml:"number,attr" json:"number,omitempty"` -} - -type Error struct { - Text string `xml:",chardata" json:"text,omitempty"` - Code string `xml:"code,attr" json:"code,omitempty"` -} - -func describeError(e Error) string { - return fmt.Sprintf(`%s (code %s)`, e.Text, e.Code) -} - -type Response struct { - XMLName xml.Name `xml:"response" json:"xml_name,omitempty"` - Text string `xml:",chardata" json:"text,omitempty"` - Status string `xml:"status" json:"status,omitempty"` - Errors struct { - Text string `xml:",chardata" json:"text,omitempty"` - Error Error `xml:"error" json:"error,omitempty"` - } `xml:"errors" json:"errors,omitempty"` - Data *Data `xml:"data" json:"data,omitempty"` -} - -type Data struct { - Text string `xml:",chardata" json:"text,omitempty"` - Service []*Service `xml:"service" json:"service,omitempty"` - Zone []*Zone `xml:"zone" json:"zone,omitempty"` - Address []*Address `xml:"address" json:"address,omitempty"` - Revision []*Revision `xml:"revision" json:"revision,omitempty"` -} diff --git a/providers/dns/nicru/internal/types.go b/providers/dns/nicru/internal/types.go new file mode 100644 index 00000000000..33f6b8bd0b0 --- /dev/null +++ b/providers/dns/nicru/internal/types.go @@ -0,0 +1,214 @@ +package internal + +import ( + "encoding/xml" + "fmt" +) + +type Request struct { + XMLName xml.Name `xml:"request"` + Text string `xml:",chardata"` + RRList *RRList `xml:"rr-list"` +} + +type RRList struct { + Text string `xml:",chardata"` + RR []RR `xml:"rr"` +} + +type RR struct { + Text string `xml:",chardata"` + ID string `xml:"id,attr,omitempty"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` + TTL string `xml:"ttl"` + Type string `xml:"type"` + SOA *SOA `xml:"soa"` + A string `xml:"a"` + AAAA string `xml:"aaaa"` + CName *CName `xml:"cname"` + NS *NS `xml:"ns"` + MX *MX `xml:"mx"` + SRV *SRV `xml:"srv"` + PTR *PTR `xml:"ptr"` + TXT *TXT `xml:"txt"` + DName *DName `xml:"dname"` + HInfo *HInfo `xml:"hinfo"` + NAPTR *NAPTR `xml:"naptr"` + RP *RP `xml:"rp"` +} + +type SOA struct { + Text string `xml:",chardata"` + MName *MName `xml:"mname"` + RName *RName `xml:"rname"` + Serial string `xml:"serial"` + Refresh string `xml:"refresh"` + Retry string `xml:"retry"` + Expire string `xml:"expire"` + Minimum string `xml:"minimum"` +} + +type MName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type RName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type NS struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type MX struct { + Text string `xml:",chardata"` + Preference string `xml:"preference"` + Exchange *Exchange `xml:"exchange"` +} + +type Exchange struct { + Name string `xml:"name"` +} + +type SRV struct { + Text string `xml:",chardata"` + Priority string `xml:"priority"` + Weight string `xml:"weight"` + Port string `xml:"port"` + Target *Target `xml:"target"` +} + +type Target struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type PTR struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type HInfo struct { + Text string `xml:",chardata"` + Hardware string `xml:"hardware"` + OS string `xml:"os"` +} + +type NAPTR struct { + Text string `xml:",chardata"` + Order string `xml:"order"` + Preference string `xml:"preference"` + Flags string `xml:"flags"` + Service string `xml:"service"` + Regexp string `xml:"regexp"` + Replacement *Replacement `xml:"replacement"` +} + +type Replacement struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type RP struct { + Text string `xml:",chardata"` + MboxDName *MboxDName `xml:"mbox-dname"` + TxtDName *TxtDName `xml:"txt-dname"` +} + +type MboxDName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type TxtDName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type CName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` + IDNName string `xml:"idn-name,omitempty"` +} + +type DName struct { + Text string `xml:",chardata"` + Name string `xml:"name"` +} + +type TXT struct { + Text string `xml:",chardata"` + String string `xml:"string"` +} + +type Response struct { + XMLName xml.Name `xml:"response"` + Text string `xml:",chardata"` + Status string `xml:"status"` + Data *Data `xml:"data"` + Errors Errors `xml:"errors"` +} + +type Data struct { + Text string `xml:",chardata"` + Service []Service `xml:"service"` + Zone []Zone `xml:"zone"` + Address []string `xml:"address"` + Revision []Revision `xml:"revision"` +} + +type Errors struct { + Text string `xml:",chardata"` + Error Error `xml:"error"` +} + +type Error struct { + Text string `xml:",chardata"` + Code string `xml:"code,attr"` +} + +func (e Error) Error() string { + return fmt.Sprintf("%s (code %s)", e.Text, e.Code) +} + +type Service struct { + Text string `xml:",chardata"` + Admin string `xml:"admin,attr"` + DomainsLimit string `xml:"domains-limit,attr"` + DomainsNum string `xml:"domains-num,attr"` + Enable string `xml:"enable,attr"` + HasPrimary string `xml:"has-primary,attr"` + Name string `xml:"name,attr"` + Payer string `xml:"payer,attr"` + Tariff string `xml:"tariff,attr"` + RRLimit string `xml:"rr-limit,attr"` + RRNum string `xml:"rr-num,attr"` +} + +type Zone struct { + Text string `xml:",chardata"` + Admin string `xml:"admin,attr"` + Enable string `xml:"enable,attr"` + HasChanges string `xml:"has-changes,attr"` + HasPrimary string `xml:"has-primary,attr"` + ID string `xml:"id,attr"` + IDNName string `xml:"idn-name,attr"` + Name string `xml:"name,attr"` + Payer string `xml:"payer,attr"` + Service string `xml:"service,attr"` + RR []RR `xml:"rr"` +} + +type Revision struct { + Text string `xml:",chardata"` + Date string `xml:"date,attr"` + IP string `xml:"ip,attr"` + Number string `xml:"number,attr"` +} diff --git a/providers/dns/nicru/nicru.go b/providers/dns/nicru/nicru.go index 3f7fc0876a6..87354a3c3db 100644 --- a/providers/dns/nicru/nicru.go +++ b/providers/dns/nicru/nicru.go @@ -1,34 +1,31 @@ +// Package nicru implements a DNS provider for solving the DNS-01 challenge using RU Center. package nicru import ( + "context" "errors" "fmt" + "strconv" + "time" + "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/nicru/internal" - "net/http" - "strconv" - "time" ) +// Environment variables names. const ( - envNamespace = "NIC_RU_" + envNamespace = "NICRU_" EnvUsername = envNamespace + "USER" EnvPassword = envNamespace + "PASSWORD" - EnvServiceId = envNamespace + "SERVICE_ID" + EnvServiceID = envNamespace + "SERVICE_ID" EnvSecret = envNamespace + "SECRET" EnvServiceName = envNamespace + "SERVICE_NAME" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" - EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" - - defaultTTL = 30 - defaultPropagationTimeout = 10 * 60 * time.Second - defaultPollingInterval = 60 * time.Second - defaultHttpTimeout = 30 * time.Second ) // Config is used to configure the creation of the DNSProvider. @@ -36,24 +33,19 @@ type Config struct { TTL int Username string Password string - ServiceId string + ServiceID string Secret string - Domain string ServiceName string PropagationTimeout time.Duration PollingInterval time.Duration - HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ - TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), - HTTPClient: &http.Client{ - Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, defaultHttpTimeout), - }, + TTL: env.GetOrDefaultInt(EnvTTL, 30), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 10*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 1*time.Minute), } } @@ -63,9 +55,9 @@ type DNSProvider struct { config *Config } -// NewDNSProvider returns a DNSProvider instance configured for NIC RU +// NewDNSProvider returns a DNSProvider instance configured for RU Center. func NewDNSProvider() (*DNSProvider, error) { - values, err := env.Get(EnvUsername, EnvPassword, EnvServiceId, EnvSecret, EnvServiceName) + values, err := env.Get(EnvUsername, EnvPassword, EnvServiceID, EnvSecret, EnvServiceName) if err != nil { return nil, fmt.Errorf("nicru: %w", err) } @@ -73,29 +65,34 @@ func NewDNSProvider() (*DNSProvider, error) { config := NewDefaultConfig() config.Username = values[EnvUsername] config.Password = values[EnvPassword] - config.ServiceId = values[EnvServiceId] + config.ServiceID = values[EnvServiceID] config.Secret = values[EnvSecret] config.ServiceName = values[EnvServiceName] return NewDNSProviderConfig(config) } -// NewDNSProviderConfig return a DNSProvider instance configured for NIC RU. +// NewDNSProviderConfig return a DNSProvider instance configured for RU Center. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("nicru: the configuration of the DNS provider is nil") } - provider := internal.Provider{ - OAuth2ClientID: config.ServiceId, + clientCfg := &internal.OauthConfiguration{ + OAuth2ClientID: config.ServiceID, OAuth2SecretID: config.Secret, Username: config.Username, Password: config.Password, - ServiceName: config.ServiceName, } - client, err := internal.NewClient(&provider) + + oauthClient, err := internal.NewOauthClient(context.Background(), clientCfg) + if err != nil { + return nil, fmt.Errorf("nicru: %w", err) + } + + client, err := internal.NewClient(oauthClient, config.ServiceName) if err != nil { - return nil, fmt.Errorf("nicru: unable to build RU CENTER client: %w", err) + return nil, fmt.Errorf("nicru: unable to build API client: %w", err) } return &DNSProvider{ @@ -105,7 +102,7 @@ func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { } // Present creates a TXT record to fulfill the dns-01 challenge. -func (r *DNSProvider) Present(domain, _, keyAuth string) error { +func (p *DNSProvider) Present(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -115,16 +112,11 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { authZone = dns01.UnFqdn(authZone) - zones, err := r.client.GetZones() - var zoneUUID string - for _, zone := range zones { - if zone.Name == authZone { - zoneUUID = zone.ID - } - } + ctx := context.Background() - if zoneUUID == "" { - return fmt.Errorf("nicru: cant find dns zone %s in nic.ru", authZone) + err = p.checkZoneUUID(ctx, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -132,7 +124,34 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { return fmt.Errorf("nicru: %w", err) } - err = r.upsertTxtRecord(authZone, subDomain, info.Value) + records, err := p.client.GetRecords(ctx, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + for _, record := range records { + if record.TXT == nil { + continue + } + + if record.TXT.Text == subDomain && record.TXT.String == info.Value { + return nil + } + } + + rrs := []internal.RR{{ + Name: subDomain, + TTL: strconv.Itoa(p.config.TTL), + Type: "TXT", + TXT: &internal.TXT{String: info.Value}, + }} + + _, err = p.client.AddRecords(ctx, authZone, rrs) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + err = p.client.CommitZone(ctx, authZone) if err != nil { return fmt.Errorf("nicru: %w", err) } @@ -141,7 +160,7 @@ func (r *DNSProvider) Present(domain, _, keyAuth string) error { } // CleanUp removes the TXT record matching the specified parameters. -func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { +func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) @@ -151,21 +170,11 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { authZone = dns01.UnFqdn(authZone) - zones, err := r.client.GetZones() - if err != nil { - return fmt.Errorf("nicru: unable to fetch dns zones: %w", err) - } + ctx := context.Background() - var zoneUUID string - - for _, zone := range zones { - if zone.Name == authZone { - zoneUUID = zone.ID - } - } - - if zoneUUID == "" { - return nil + err = p.checkZoneUUID(ctx, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) } subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) @@ -173,62 +182,58 @@ func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { return fmt.Errorf("nicru: %w", err) } - err = r.removeTxtRecord(authZone, subDomain, info.Value) + records, err := p.client.GetRecords(ctx, authZone) if err != nil { return fmt.Errorf("nicru: %w", err) } - return nil -} + subDomain = dns01.UnFqdn(subDomain) -// Timeout returns the timeout and interval to use when checking for DNS propagation. -// Adjusting here to cope with spikes in propagation times. -func (r *DNSProvider) Timeout() (timeout, interval time.Duration) { - return r.config.PropagationTimeout, r.config.PollingInterval -} + for _, record := range records { + if record.TXT == nil { + continue + } -func (r *DNSProvider) upsertTxtRecord(zone, name, value string) error { - records, err := r.client.GetTXTRecords(zone) - if err != nil { - return err - } + if record.Name != subDomain || record.TXT.String != info.Value { + continue + } - for _, record := range records { - if record.Text == name && record.String == value { - return nil + err = p.client.DeleteRecord(ctx, authZone, record.ID) + if err != nil { + return fmt.Errorf("nicru: %w", err) } } - _, err = r.client.AddTxtRecord(zone, name, value, r.config.TTL) + err = p.client.CommitZone(ctx, authZone) if err != nil { - return err + return fmt.Errorf("nicru: %w", err) } - _, err = r.client.CommitZone(zone) - return err + + 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 (p *DNSProvider) Timeout() (timeout, interval time.Duration) { + return p.config.PropagationTimeout, p.config.PollingInterval } -func (r *DNSProvider) removeTxtRecord(zone, name, value string) error { - records, err := r.client.GetRecords(zone) +func (p *DNSProvider) checkZoneUUID(ctx context.Context, authZone string) error { + zones, err := p.client.GetZones(ctx) if err != nil { - return err + return fmt.Errorf("unable to fetch dns zones: %w", err) } - name = dns01.UnFqdn(name) - for _, record := range records { - if record.Txt != nil { - if record.Name == name && record.Txt.String == value { - _id, err := strconv.Atoi(record.ID) - if err != nil { - return err - } - _, err = r.client.DeleteRecord(zone, _id) - if err != nil { - return err - } - } + var zoneUUID string + for _, zone := range zones { + if zone.Name == authZone { + zoneUUID = zone.ID } } - _, err = r.client.CommitZone(zone) - return err + if zoneUUID == "" { + return fmt.Errorf("zone UUID not found for %s", authZone) + } + + return nil } diff --git a/providers/dns/nicru/nicru.toml b/providers/dns/nicru/nicru.toml index 132adcfe6e1..01354bc1d26 100644 --- a/providers/dns/nicru/nicru.toml +++ b/providers/dns/nicru/nicru.toml @@ -2,14 +2,14 @@ Name = "RU CENTER" Description = '''''' URL = "https://nic.ru/" Code = "nicru" -Since = "v4.11.0" +Since = "v4.12.0" Example = ''' -NIC_RU_USER="" \ -NIC_RU_PASSWORD="" \ -NIC_RU_SERVICE_ID="" \ -NIC_RU_SECRET="" \ -NIC_RU_SERVICE_NAME="" \ +NICRU_USER="" \ +NICRU_PASSWORD="" \ +NICRU_SERVICE_ID="" \ +NICRU_SECRET="" \ +NICRU_SERVICE_NAME="" \ ./lego --dns nicru --domains "*.example.com" --email you@example.com run ''' @@ -18,27 +18,26 @@ Additional = ''' You can find information about service ID and secret https://www.nic.ru/manager/oauth.cgi?step=oauth.app_list -| ENV Variable | Parameter from page | Example | -|----------------------|--------------------------------|-------------------| -| NIC_RU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | -| NIC_RU_PASSWORD | Password account | | -| NIC_RU_SERVICE_ID | Application ID | hex-based, len 32 | -| NIC_RU_SECRET | Identity endpoint | string len 91 | -| NIC_RU_SERVICE_NAME | Service name in DNS-hosting | DPNNNNNNNNNN | +| ENV Variable | Parameter from page | Example | +|---------------------|--------------------------------|-------------------| +| NICRU_USER | Username (Number of agreement) | NNNNNNN/NIC-D | +| NICRU_PASSWORD | Password account | | +| NICRU_SERVICE_ID | Application ID | hex-based, len 32 | +| NICRU_SECRET | Identity endpoint | string len 91 | +| NICRU_SERVICE_NAME | Service name in DNS-hosting | DPNNNNNNNNNN | ''' [Configuration] [Configuration.Credentials] - NIC_RU_USER = "Agreement for account in RU CENTER" - NIC_RU_PASSWORD = "Password for account in RU CENTER" - NIC_RU_SERVICE_ID = "Service ID for application in DNS-hosting RU CENTER" - NIC_RU_SECRET = "Secret for application in DNS-hosting RU CENTER" - NIC_RU_SERVICE_NAME = "Service Name for DNS-hosting RU CENTER" + NICRU_USER = "Agreement for account in RU CENTER" + NICRU_PASSWORD = "Password for account in RU CENTER" + NICRU_SERVICE_ID = "Service ID for application in DNS-hosting RU CENTER" + NICRU_SECRET = "Secret for application in DNS-hosting RU CENTER" + NICRU_SERVICE_NAME = "Service Name for DNS-hosting RU CENTER" [Configuration.Additional] - NIC_RU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" - NIC_RU_POLLING_INTERVAL = "Time between DNS propagation check" - NIC_RU_TTL = "The TTL of the TXT record used for the DNS challenge" - NIC_RU_HTTP_TIMEOUT = "API request timeout" + NICRU_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + NICRU_POLLING_INTERVAL = "Time between DNS propagation check" + NICRU_TTL = "The TTL of the TXT record used for the DNS challenge" [Links] API = "https://www.nic.ru/help/api-dns-hostinga_3643.html" diff --git a/providers/dns/nicru/nicru_test.go b/providers/dns/nicru/nicru_test.go index 475f8185650..14d1ded1dbb 100644 --- a/providers/dns/nicru/nicru_test.go +++ b/providers/dns/nicru/nicru_test.go @@ -1,23 +1,23 @@ package nicru import ( + "testing" + "github.com/go-acme/lego/v4/platform/tester" "github.com/stretchr/testify/require" - "testing" ) -const defaultDomainName = "example.com" -const envDomain = envNamespace + "DOMAIN" - const ( - fakeServiceId = "2519234972459cdfa23423adf143324f" + fakeServiceID = "2519234972459cdfa23423adf143324f" fakeSecret = "oo5ahrie0aiPho3Vee4siupoPhahdahCh1thiesohru" fakeServiceName = "DS1234567890" fakeUsername = "1234567/NIC-D" fakePassword = "einge8Goo2eBaiXievuj" ) -var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvServiceId, EnvSecret, EnvServiceName).WithDomain(envDomain) +const envDomain = envNamespace + "DOMAIN" + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvServiceID, EnvSecret, EnvServiceName).WithDomain(envDomain) func TestNewDNSProvider(t *testing.T) { testCases := []struct { @@ -28,62 +28,63 @@ func TestNewDNSProvider(t *testing.T) { { desc: "success", envVars: map[string]string{ - EnvServiceId: fakeServiceId, + EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvServiceName: fakeServiceName, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, + expected: "nicru: failed to create oauth2 token: oauth2: cannot fetch token: 401 Unauthorized\nResponse: {\"error\":\"invalid_client\"}", }, { - desc: "missing serviceId", + desc: "missing serviceID", envVars: map[string]string{ EnvSecret: fakeSecret, EnvServiceName: fakeServiceName, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, - expected: "nicru: some credentials information are missing: NIC_RU_SERVICE_ID", + expected: "nicru: some credentials information are missing: NICRU_SERVICE_ID", }, { desc: "missing secret", envVars: map[string]string{ - EnvServiceId: fakeServiceId, + EnvServiceID: fakeServiceID, EnvServiceName: fakeServiceName, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, - expected: "nicru: some credentials information are missing: NIC_RU_SECRET", + expected: "nicru: some credentials information are missing: NICRU_SECRET", }, { desc: "missing service name", envVars: map[string]string{ - EnvServiceId: fakeServiceId, + EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvUsername: fakeUsername, EnvPassword: fakePassword, }, - expected: "nicru: some credentials information are missing: NIC_RU_SERVICE_NAME", + expected: "nicru: some credentials information are missing: NICRU_SERVICE_NAME", }, { desc: "missing username", envVars: map[string]string{ - EnvServiceId: fakeServiceId, + EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvServiceName: fakeServiceName, EnvPassword: fakePassword, }, - expected: "nicru: some credentials information are missing: NIC_RU_USER", + expected: "nicru: some credentials information are missing: NICRU_USER", }, { desc: "missing password", envVars: map[string]string{ - EnvServiceId: fakeServiceId, + EnvServiceID: fakeServiceID, EnvSecret: fakeSecret, EnvServiceName: fakeServiceName, EnvUsername: fakeUsername, }, - expected: "nicru: some credentials information are missing: NIC_RU_PASSWORD", + expected: "nicru: some credentials information are missing: NICRU_PASSWORD", }, } @@ -116,79 +117,52 @@ func TestNewDNSProviderConfig(t *testing.T) { { desc: "success", config: &Config{ - ServiceId: fakeServiceId, - Secret: fakeSecret, - ServiceName: fakeServiceName, - Username: fakeUsername, - Password: fakePassword, - TTL: defaultTTL, - PropagationTimeout: defaultPropagationTimeout, - PollingInterval: defaultPollingInterval, + ServiceID: fakeServiceID, + Secret: fakeSecret, + Username: fakeUsername, + Password: fakePassword, }, + expected: "nicru: failed to create oauth2 token: oauth2: cannot fetch token: 401 Unauthorized\nResponse: {\"error\":\"invalid_client\"}", }, { desc: "nil config", config: nil, expected: "nicru: the configuration of the DNS provider is nil", }, - { - desc: "missing service name", - config: &Config{ - Username: fakeUsername, - Password: fakePassword, - TTL: defaultTTL, - PropagationTimeout: defaultPropagationTimeout, - PollingInterval: defaultPollingInterval, - }, - expected: "nicru: unable to build RU CENTER client: service name is missing in credentials information", - }, { desc: "missing username", config: &Config{ - ServiceName: fakeServiceName, - ServiceId: fakeServiceId, - Password: fakePassword, - TTL: defaultTTL, - PropagationTimeout: defaultPropagationTimeout, - PollingInterval: defaultPollingInterval, + ServiceID: fakeServiceID, + Password: fakePassword, }, - expected: "nicru: unable to build RU CENTER client: username is missing in credentials information", + expected: "nicru: username is missing in credentials information", }, { desc: "missing password", config: &Config{ - ServiceName: fakeServiceName, - ServiceId: fakeServiceId, - Secret: fakeSecret, - Username: fakeUsername, - TTL: defaultTTL, - PropagationTimeout: defaultPropagationTimeout, - PollingInterval: defaultPollingInterval, + ServiceID: fakeServiceID, + Secret: fakeSecret, + Username: fakeUsername, }, - expected: "nicru: unable to build RU CENTER client: password is missing in credentials information", + expected: "nicru: password is missing in credentials information", }, { desc: "missing secret", config: &Config{ - ServiceId: fakeServiceId, - ServiceName: fakeServiceName, - Username: fakeUsername, - Password: fakePassword, - PropagationTimeout: defaultPropagationTimeout, - PollingInterval: defaultPollingInterval, + ServiceID: fakeServiceID, + Username: fakeUsername, + Password: fakePassword, }, - expected: "nicru: unable to build RU CENTER client: secret is missing in credentials information", + expected: "nicru: secret is missing in credentials information", }, { - desc: "missing serviceId", + desc: "missing serviceID", config: &Config{ - ServiceName: fakeServiceName, - Secret: fakeSecret, - Username: fakeUsername, - Password: fakePassword, - Domain: defaultDomainName, + Secret: fakeSecret, + Username: fakeUsername, + Password: fakePassword, }, - expected: "nicru: unable to build RU CENTER client: serviceId is missing in credentials information", + expected: "nicru: serviceID is missing in credentials information", }, }