From 231ca8a2f284ac5ab03638832c6f1f61e299d967 Mon Sep 17 00:00:00 2001 From: Anton Dzyk Date: Wed, 12 Apr 2023 14:06:57 +0300 Subject: [PATCH] Add DNS provider for RU CENTER (#1891) --- providers/dns/nicru/internal/client.go | 297 +++++++++++++++++++++++++ providers/dns/nicru/internal/model.go | 219 ++++++++++++++++++ providers/dns/nicru/nicru.go | 234 +++++++++++++++++++ providers/dns/nicru/nicru.toml | 44 ++++ providers/dns/nicru/nicru_test.go | 234 +++++++++++++++++++ 5 files changed, 1028 insertions(+) create mode 100644 providers/dns/nicru/internal/client.go create mode 100644 providers/dns/nicru/internal/model.go create mode 100644 providers/dns/nicru/nicru.go create mode 100644 providers/dns/nicru/nicru.toml create mode 100644 providers/dns/nicru/nicru_test.go 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) +}