diff --git a/README.md b/README.md index 3ded66aeba..4ab501121d 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,14 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Nodion](https://go-acme.github.io/lego/dns/nodion/) | [NS1](https://go-acme.github.io/lego/dns/ns1/) | [Open Telekom Cloud](https://go-acme.github.io/lego/dns/otc/) | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [plesk.com](https://go-acme.github.io/lego/dns/plesk/) | [Porkbun](https://go-acme.github.io/lego/dns/porkbun/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RcodeZero](https://go-acme.github.io/lego/dns/rcodezero/) | [reg.ru](https://go-acme.github.io/lego/dns/regru/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | -| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | [Selectel](https://go-acme.github.io/lego/dns/selectel/) | -| [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | -| [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) | -| [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | -| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | -| [Webnames](https://go-acme.github.io/lego/dns/webnames/) | [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex 360](https://go-acme.github.io/lego/dns/yandex360/) | -| [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | +| [RimuHosting](https://go-acme.github.io/lego/dns/rimuhosting/) | [RU CENTER](https://go-acme.github.io/lego/dns/nicru/) | [Sakura Cloud](https://go-acme.github.io/lego/dns/sakuracloud/) | [Scaleway](https://go-acme.github.io/lego/dns/scaleway/) | +| [Selectel](https://go-acme.github.io/lego/dns/selectel/) | [Servercow](https://go-acme.github.io/lego/dns/servercow/) | [Simply.com](https://go-acme.github.io/lego/dns/simply/) | [Sonic](https://go-acme.github.io/lego/dns/sonic/) | +| [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [Tencent Cloud DNS](https://go-acme.github.io/lego/dns/tencentcloud/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [UKFast SafeDNS](https://go-acme.github.io/lego/dns/safedns/) | +| [Ultradns](https://go-acme.github.io/lego/dns/ultradns/) | [Variomedia](https://go-acme.github.io/lego/dns/variomedia/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vercel](https://go-acme.github.io/lego/dns/vercel/) | +| [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [VK Cloud](https://go-acme.github.io/lego/dns/vkcloud/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Webnames](https://go-acme.github.io/lego/dns/webnames/) | [Websupport](https://go-acme.github.io/lego/dns/websupport/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | +| [Yandex 360](https://go-acme.github.io/lego/dns/yandex360/) | [Yandex Cloud](https://go-acme.github.io/lego/dns/yandexcloud/) | [Yandex PDD](https://go-acme.github.io/lego/dns/yandex/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | +| [Zonomi](https://go-acme.github.io/lego/dns/zonomi/) | | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 07626d5be4..8147a0e8c4 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -98,6 +98,7 @@ func allDNSCodes() string { "netcup", "netlify", "nicmanager", + "nicru", "nifcloud", "njalla", "nodion", @@ -1936,6 +1937,30 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicmanager`) + case "nicru": + // 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() + + 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() + + 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() + ew.writeln(`More information: https://go-acme.github.io/lego/dns/nicru`) + case "nifcloud": // generated from: providers/dns/nifcloud/nifcloud.toml ew.writeln(`Configuration for NIFCloud.`) diff --git a/docs/content/dns/zz_gen_nicru.md b/docs/content/dns/zz_gen_nicru.md new file mode 100644 index 0000000000..595c1238a3 --- /dev/null +++ b/docs/content/dns/zz_gen_nicru.md @@ -0,0 +1,86 @@ +--- +title: "RU CENTER" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: nicru +dnsprovider: + since: "v4.11.0" + code: "nicru" + url: "https://nic.ru/" +--- + + + + + + +Configuration for [RU CENTER](https://nic.ru/). + + + + +- Code: `nicru` +- Since: v4.11.0 + + +Here is an example bash command using the RU CENTER provider: + +```bash +NIC_RU_USER="" \ +NIC_RU_PASSWORD="" \ +NIC_RU_SERVICE_ID="" \ +NIC_RU_SECRET="" \ +NIC_RU_SERVICE_NAME="" \ +./lego --dns nicru --domains "*.example.com" --email you@example.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `NIC_RU_PASSWORD` | Password for account in RU CENTER | +| `NIC_RU_SECRET` | Secret for application in DNS-hosting RU CENTER | +| `NIC_RU_SERVICE_ID` | Service ID for application in DNS-hosting RU CENTER | +| `NIC_RU_SERVICE_NAME` | Service Name for DNS-hosting RU CENTER | +| `NIC_RU_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 | +|--------------------------------|-------------| +| `NIC_RU_HTTP_TIMEOUT` | API request timeout | +| `NIC_RU_POLLING_INTERVAL` | Time between DNS propagation check | +| `NIC_RU_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `NIC_RU_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 | +|----------------------|--------------------------------|-------------------| +| 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 | + + + +## More information + +- [API documentation](https://www.nic.ru/help/api-dns-hostinga_3643.html) + + + + diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 7254d8c234..6b0ac31ff4 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -137,7 +137,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, cpanel, derak, desec, designate, digitalocean, 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, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, linode, liquidweb, loopia, luadns, manual, metaname, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, servercow, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, 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, cpanel, derak, desec, designate, digitalocean, 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, hurricane, hyperone, ibmcloud, iij, iijdpf, infoblox, infomaniak, internetbs, inwx, ionos, ipv64, iwantmyname, joker, liara, lightsail, linode, liquidweb, loopia, luadns, manual, metaname, mydnsjp, mythicbeasts, namecheap, namedotcom, namesilo, nearlyfreespeech, netcup, netlify, nicmanager, nicru, nifcloud, njalla, nodion, ns1, oraclecloud, otc, ovh, pdns, plesk, porkbun, rackspace, rcodezero, regru, rfc2136, rimuhosting, route53, safedns, sakuracloud, scaleway, selectel, servercow, simply, sonic, stackpath, tencentcloud, transip, ultradns, variomedia, vegadns, vercel, versio, vinyldns, vkcloud, vscale, vultr, webnames, websupport, wedos, yandex, yandex360, yandexcloud, zoneee, zonomi More information: https://go-acme.github.io/lego/dns """ diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 408a87c46f..6e880f04fe 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -88,6 +88,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/netcup" "github.com/go-acme/lego/v4/providers/dns/netlify" "github.com/go-acme/lego/v4/providers/dns/nicmanager" + "github.com/go-acme/lego/v4/providers/dns/nicru" "github.com/go-acme/lego/v4/providers/dns/nifcloud" "github.com/go-acme/lego/v4/providers/dns/njalla" "github.com/go-acme/lego/v4/providers/dns/nodion" @@ -304,6 +305,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return netlify.NewDNSProvider() case "nicmanager": return nicmanager.NewDNSProvider() + case "nicru": + return nicru.NewDNSProvider() case "nifcloud": return nifcloud.NewDNSProvider() case "njalla": diff --git a/providers/dns/nicru/internal/client.go b/providers/dns/nicru/internal/client.go new file mode 100644 index 0000000000..ee4fb13e9c --- /dev/null +++ b/providers/dns/nicru/internal/client.go @@ -0,0 +1,297 @@ +package internal + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "golang.org/x/oauth2" + "net/http" + "strconv" +) + +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/.+` +) + +// 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"` +} + +type Client struct { + client *http.Client + provider *Provider + token string +} + +func NewClient(provider *Provider) (*Client, error) { + client := Client{provider: provider} + err := client.validateAuthOptions() + if err != nil { + return nil, err + } + return &client, nil +} + +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()) + } + + client.client = oauth2Config.Client(ctx, oauth2Token) + return nil +} + +func (client *Client) Do(r *http.Request) (*http.Response, error) { + if client.client == nil { + err := client.GetOauth2Client() + if err != nil { + return nil, err + } + } + 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 + } + + buf := bytes.NewBuffer(nil) + if _, err := buf.ReadFrom(response.Body); err != nil { + return nil, err + } + + 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 + } +} + +func (client *Client) GetRecords(fqdn string) ([]*RR, error) { + request, err := http.NewRequest( + http.MethodGet, + fmt.Sprintf(GetRecordsUrlPattern, client.provider.ServiceName, fqdn), + nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + + buf := bytes.NewBuffer(nil) + if _, err := buf.ReadFrom(response.Body); err != nil { + return nil, err + } + + 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 + } +} + +func (client *Client) add(zoneName string, request *Request) (*Response, error) { + + 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) + if err != nil { + return nil, err + } + + response, err := client.Do(req) + if err != nil { + return nil, err + } + + buf = bytes.NewBuffer(nil) + if _, err := buf.ReadFrom(response.Body); err != nil { + return nil, err + } + + apiResponse := &Response{} + if err := xml.NewDecoder(buf).Decode(&apiResponse); err != nil { + return nil, err + } + + if apiResponse.Status != SuccessStatus { + return nil, fmt.Errorf(describeError(apiResponse.Errors.Error)) + } else { + return apiResponse, 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) + if err != nil { + return nil, err + } + response, err := client.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) GetTXTRecords(fqdn string) ([]*Txt, error) { + records, err := client.GetRecords(fqdn) + if err != nil { + return nil, err + } + + txt := make([]*Txt, 0) + for _, record := range records { + if record.Txt != nil { + txt = append(txt, record.Txt) + } + } + + return txt, 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 (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 { + return nil, err + } + response, err := client.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) 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) + 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" + + if client.provider.ServiceName == "" { + return errors.New("service name" + msg) + } + + if client.provider.Username == "" { + return errors.New("username" + msg) + } + + if client.provider.Password == "" { + return errors.New("password" + msg) + } + + if client.provider.OAuth2ClientID == "" { + return errors.New("serviceId" + msg) + } + + if client.provider.OAuth2SecretID == "" { + return errors.New("secret" + msg) + } + + return nil +} diff --git a/providers/dns/nicru/internal/model.go b/providers/dns/nicru/internal/model.go new file mode 100644 index 0000000000..a95e40ecf4 --- /dev/null +++ b/providers/dns/nicru/internal/model.go @@ -0,0 +1,219 @@ +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/nicru.go b/providers/dns/nicru/nicru.go new file mode 100644 index 0000000000..3f7fc0876a --- /dev/null +++ b/providers/dns/nicru/nicru.go @@ -0,0 +1,234 @@ +package nicru + +import ( + "errors" + "fmt" + "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" +) + +const ( + envNamespace = "NIC_RU_" + + EnvUsername = envNamespace + "USER" + EnvPassword = envNamespace + "PASSWORD" + 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. +type Config struct { + TTL int + Username string + Password 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), + }, + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + client *internal.Client + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for NIC RU +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvUsername, EnvPassword, EnvServiceId, EnvSecret, EnvServiceName) + if err != nil { + return nil, fmt.Errorf("nicru: %w", err) + } + + config := NewDefaultConfig() + config.Username = values[EnvUsername] + config.Password = values[EnvPassword] + config.ServiceId = values[EnvServiceId] + config.Secret = values[EnvSecret] + config.ServiceName = values[EnvServiceName] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for NIC RU. +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, + OAuth2SecretID: config.Secret, + Username: config.Username, + Password: config.Password, + ServiceName: config.ServiceName, + } + client, err := internal.NewClient(&provider) + if err != nil { + return nil, fmt.Errorf("nicru: unable to build RU CENTER client: %w", err) + } + + return &DNSProvider{ + client: client, + config: config, + }, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (r *DNSProvider) Present(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + authZone = dns01.UnFqdn(authZone) + + zones, err := r.client.GetZones() + var zoneUUID string + for _, zone := range zones { + if zone.Name == authZone { + zoneUUID = zone.ID + } + } + + if zoneUUID == "" { + return fmt.Errorf("nicru: cant find dns zone %s in nic.ru", authZone) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + err = r.upsertTxtRecord(authZone, subDomain, info.Value) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (r *DNSProvider) CleanUp(domain, _, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + authZone = dns01.UnFqdn(authZone) + + zones, err := r.client.GetZones() + if err != nil { + return fmt.Errorf("nicru: unable to fetch dns zones: %w", err) + } + + var zoneUUID string + + for _, zone := range zones { + if zone.Name == authZone { + zoneUUID = zone.ID + } + } + + if zoneUUID == "" { + return nil + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("nicru: %w", err) + } + + err = r.removeTxtRecord(authZone, subDomain, info.Value) + if err != nil { + return fmt.Errorf("nicru: %w", 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 (r *DNSProvider) Timeout() (timeout, interval time.Duration) { + return r.config.PropagationTimeout, r.config.PollingInterval +} + +func (r *DNSProvider) upsertTxtRecord(zone, name, value string) error { + records, err := r.client.GetTXTRecords(zone) + if err != nil { + return err + } + + for _, record := range records { + if record.Text == name && record.String == value { + return nil + } + } + + _, err = r.client.AddTxtRecord(zone, name, value, r.config.TTL) + if err != nil { + return err + } + _, err = r.client.CommitZone(zone) + return err +} + +func (r *DNSProvider) removeTxtRecord(zone, name, value string) error { + records, err := r.client.GetRecords(zone) + if err != nil { + return 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 + } + } + } + } + + _, err = r.client.CommitZone(zone) + return err +} diff --git a/providers/dns/nicru/nicru.toml b/providers/dns/nicru/nicru.toml new file mode 100644 index 0000000000..132adcfe6e --- /dev/null +++ b/providers/dns/nicru/nicru.toml @@ -0,0 +1,44 @@ +Name = "RU CENTER" +Description = '''''' +URL = "https://nic.ru/" +Code = "nicru" +Since = "v4.11.0" + +Example = ''' +NIC_RU_USER="" \ +NIC_RU_PASSWORD="" \ +NIC_RU_SERVICE_ID="" \ +NIC_RU_SECRET="" \ +NIC_RU_SERVICE_NAME="" \ +./lego --dns nicru --domains "*.example.com" --email you@example.com run +''' + +Additional = ''' +## 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 | +|----------------------|--------------------------------|-------------------| +| 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 | +''' + +[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" + [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" + +[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 new file mode 100644 index 0000000000..475f818565 --- /dev/null +++ b/providers/dns/nicru/nicru_test.go @@ -0,0 +1,234 @@ +package nicru + +import ( + "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" + fakeSecret = "oo5ahrie0aiPho3Vee4siupoPhahdahCh1thiesohru" + fakeServiceName = "DS1234567890" + fakeUsername = "1234567/NIC-D" + fakePassword = "einge8Goo2eBaiXievuj" +) + +var envTest = tester.NewEnvTest(EnvUsername, EnvPassword, EnvServiceId, EnvSecret, EnvServiceName).WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvServiceId: fakeServiceId, + EnvSecret: fakeSecret, + EnvServiceName: fakeServiceName, + EnvUsername: fakeUsername, + EnvPassword: fakePassword, + }, + }, + { + 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", + }, + { + desc: "missing secret", + envVars: map[string]string{ + EnvServiceId: fakeServiceId, + EnvServiceName: fakeServiceName, + EnvUsername: fakeUsername, + EnvPassword: fakePassword, + }, + expected: "nicru: some credentials information are missing: NIC_RU_SECRET", + }, + { + desc: "missing service name", + envVars: map[string]string{ + EnvServiceId: fakeServiceId, + EnvSecret: fakeSecret, + EnvUsername: fakeUsername, + EnvPassword: fakePassword, + }, + expected: "nicru: some credentials information are missing: NIC_RU_SERVICE_NAME", + }, + { + desc: "missing username", + envVars: map[string]string{ + EnvServiceId: fakeServiceId, + EnvSecret: fakeSecret, + EnvServiceName: fakeServiceName, + EnvPassword: fakePassword, + }, + expected: "nicru: some credentials information are missing: NIC_RU_USER", + }, + { + desc: "missing password", + envVars: map[string]string{ + EnvServiceId: fakeServiceId, + EnvSecret: fakeSecret, + EnvServiceName: fakeServiceName, + EnvUsername: fakeUsername, + }, + expected: "nicru: some credentials information are missing: NIC_RU_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) + } else { + require.EqualError(t, err, test.expected) + } + }) + } +} + +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + config *Config + expected string + }{ + { + desc: "success", + config: &Config{ + ServiceId: fakeServiceId, + Secret: fakeSecret, + ServiceName: fakeServiceName, + Username: fakeUsername, + Password: fakePassword, + TTL: defaultTTL, + PropagationTimeout: defaultPropagationTimeout, + PollingInterval: defaultPollingInterval, + }, + }, + { + 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, + }, + expected: "nicru: unable to build RU CENTER client: 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, + }, + expected: "nicru: unable to build RU CENTER client: password is missing in credentials information", + }, + { + desc: "missing secret", + config: &Config{ + ServiceId: fakeServiceId, + ServiceName: fakeServiceName, + Username: fakeUsername, + Password: fakePassword, + PropagationTimeout: defaultPropagationTimeout, + PollingInterval: defaultPollingInterval, + }, + expected: "nicru: unable to build RU CENTER client: secret is missing in credentials information", + }, + { + desc: "missing serviceId", + config: &Config{ + ServiceName: fakeServiceName, + Secret: fakeSecret, + Username: fakeUsername, + Password: fakePassword, + Domain: defaultDomainName, + }, + expected: "nicru: unable to build RU CENTER client: serviceId is missing in credentials information", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + p, err := NewDNSProviderConfig(test.config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + } 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) +}