From 837082bc1bcef4582f8c852727c3c93e39a43cff Mon Sep 17 00:00:00 2001 From: Ludovic Fernandez Date: Tue, 25 Feb 2020 21:56:40 +0100 Subject: [PATCH] Add DNS provider for Constellix (#1051) --- README.md | 27 ++- cmd/zz_gen_cmd_dnshelp.go | 22 ++ docs/content/dns/zz_gen_constellix.md | 64 +++++ go.mod | 1 + go.sum | 1 + providers/dns/constellix/constellix.go | 223 ++++++++++++++++++ providers/dns/constellix/constellix.toml | 24 ++ providers/dns/constellix/constellix_test.go | 150 ++++++++++++ providers/dns/constellix/internal/auth.go | 90 +++++++ .../dns/constellix/internal/auth_test.go | 43 ++++ providers/dns/constellix/internal/client.go | 107 +++++++++ providers/dns/constellix/internal/domains.go | 81 +++++++ .../dns/constellix/internal/domains_test.go | 86 +++++++ .../internal/fixtures/domains-01.json | 37 +++ .../internal/fixtures/domains-02.json | 142 +++++++++++ .../internal/fixtures/records-01.json | 25 ++ .../internal/fixtures/records-02.json | 23 ++ providers/dns/constellix/internal/model.go | 72 ++++++ .../dns/constellix/internal/txtrecords.go | 132 +++++++++++ .../constellix/internal/txtrecords_test.go | 182 ++++++++++++++ providers/dns/dns_providers.go | 3 + 21 files changed, 1522 insertions(+), 13 deletions(-) create mode 100644 docs/content/dns/zz_gen_constellix.md create mode 100644 providers/dns/constellix/constellix.go create mode 100644 providers/dns/constellix/constellix.toml create mode 100644 providers/dns/constellix/constellix_test.go create mode 100644 providers/dns/constellix/internal/auth.go create mode 100644 providers/dns/constellix/internal/auth_test.go create mode 100644 providers/dns/constellix/internal/client.go create mode 100644 providers/dns/constellix/internal/domains.go create mode 100644 providers/dns/constellix/internal/domains_test.go create mode 100644 providers/dns/constellix/internal/fixtures/domains-01.json create mode 100644 providers/dns/constellix/internal/fixtures/domains-02.json create mode 100644 providers/dns/constellix/internal/fixtures/records-01.json create mode 100644 providers/dns/constellix/internal/fixtures/records-02.json create mode 100644 providers/dns/constellix/internal/model.go create mode 100644 providers/dns/constellix/internal/txtrecords.go create mode 100644 providers/dns/constellix/internal/txtrecords_test.go diff --git a/README.md b/README.md index 20baf10b..5116b9be 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,17 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Alibaba Cloud DNS](https://go-acme.github.io/lego/dns/alidns/) | [Amazon Lightsail](https://go-acme.github.io/lego/dns/lightsail/) | [Amazon Route 53](https://go-acme.github.io/lego/dns/route53/) | [Aurora DNS](https://go-acme.github.io/lego/dns/auroradns/) | | [Autodns](https://go-acme.github.io/lego/dns/autodns/) | [Azure](https://go-acme.github.io/lego/dns/azure/) | [Bindman](https://go-acme.github.io/lego/dns/bindman/) | [Bluecat](https://go-acme.github.io/lego/dns/bluecat/) | | [Checkdomain](https://go-acme.github.io/lego/dns/checkdomain/) | [Cloudflare](https://go-acme.github.io/lego/dns/cloudflare/) | [ClouDNS](https://go-acme.github.io/lego/dns/cloudns/) | [CloudXNS](https://go-acme.github.io/lego/dns/cloudxns/) | -| [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) | [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) | -| [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod](https://go-acme.github.io/lego/dns/dnspod/) | [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) | [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | -| [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) | [Dyn](https://go-acme.github.io/lego/dns/dyn/) | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | -| [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS](https://go-acme.github.io/lego/dns/fastdns/) | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | [Gandi](https://go-acme.github.io/lego/dns/gandi/) | -| [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | -| [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | -| [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | -| [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | -| [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [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/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | -| [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [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/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | -| [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Versio](https://go-acme.github.io/lego/dns/versio/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | +| [ConoHa](https://go-acme.github.io/lego/dns/conoha/) | [Constellix](https://go-acme.github.io/lego/dns/constellix/) | [Designate DNSaaS for Openstack](https://go-acme.github.io/lego/dns/designate/) | [Digital Ocean](https://go-acme.github.io/lego/dns/digitalocean/) | +| [DNS Made Easy](https://go-acme.github.io/lego/dns/dnsmadeeasy/) | [DNSimple](https://go-acme.github.io/lego/dns/dnsimple/) | [DNSPod](https://go-acme.github.io/lego/dns/dnspod/) | [Domain Offensive (do.de)](https://go-acme.github.io/lego/dns/dode/) | +| [DreamHost](https://go-acme.github.io/lego/dns/dreamhost/) | [Duck DNS](https://go-acme.github.io/lego/dns/duckdns/) | [Dyn](https://go-acme.github.io/lego/dns/dyn/) | [EasyDNS](https://go-acme.github.io/lego/dns/easydns/) | +| [Exoscale](https://go-acme.github.io/lego/dns/exoscale/) | [External program](https://go-acme.github.io/lego/dns/exec/) | [FastDNS](https://go-acme.github.io/lego/dns/fastdns/) | [Gandi Live DNS (v5)](https://go-acme.github.io/lego/dns/gandiv5/) | +| [Gandi](https://go-acme.github.io/lego/dns/gandi/) | [Glesys](https://go-acme.github.io/lego/dns/glesys/) | [Go Daddy](https://go-acme.github.io/lego/dns/godaddy/) | [Google Cloud](https://go-acme.github.io/lego/dns/gcloud/) | +| [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | +| [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns) | [Linode (deprecated)](https://go-acme.github.io/lego/dns/linode/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linodev4/) | +| [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [Name.com](https://go-acme.github.io/lego/dns/namedotcom/) | +| [Namecheap](https://go-acme.github.io/lego/dns/namecheap/) | [Namesilo](https://go-acme.github.io/lego/dns/namesilo/) | [Netcup](https://go-acme.github.io/lego/dns/netcup/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | +| [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/) | +| [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | [RFC2136](https://go-acme.github.io/lego/dns/rfc2136/) | [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/) | [Stackpath](https://go-acme.github.io/lego/dns/stackpath/) | +| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Versio](https://go-acme.github.io/lego/dns/versio/) | +| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [Zone.ee](https://go-acme.github.io/lego/dns/zoneee/) | | | diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index e25f87c7..7018c024 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -26,6 +26,7 @@ func allDNSCodes() string { "cloudns", "cloudxns", "conoha", + "constellix", "designate", "digitalocean", "dnsimple", @@ -349,6 +350,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/conoha`) + case "constellix": + // generated from: providers/dns/constellix/constellix.toml + ew.writeln(`Configuration for Constellix.`) + ew.writeln(`Code: 'constellix'`) + ew.writeln(`Since: 'v0.3.4'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "CONSTELLIX_API_KEY": User API key`) + ew.writeln(` - "CONSTELLIX_SECRET_KEY": User secret key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "CONSTELLIX_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "CONSTELLIX_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "CONSTELLIX_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "CONSTELLIX_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/constellix`) + case "designate": // generated from: providers/dns/designate/designate.toml ew.writeln(`Configuration for Designate DNSaaS for Openstack.`) diff --git a/docs/content/dns/zz_gen_constellix.md b/docs/content/dns/zz_gen_constellix.md new file mode 100644 index 00000000..f66cece4 --- /dev/null +++ b/docs/content/dns/zz_gen_constellix.md @@ -0,0 +1,64 @@ +--- +title: "Constellix" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: constellix +--- + + + + + +Since: v0.3.4 + +Configuration for [Constellix](https://constellix.com). + + + + +- Code: `constellix` + +Here is an example bash command using the Constellix provider: + +```bash +CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ +CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ +lego --dns constellix --domains my.domain.com --email my@email.com run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `CONSTELLIX_API_KEY` | User API key | +| `CONSTELLIX_SECRET_KEY` | User secret key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `CONSTELLIX_HTTP_TIMEOUT` | API request timeout | +| `CONSTELLIX_POLLING_INTERVAL` | Time between DNS propagation check | +| `CONSTELLIX_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `CONSTELLIX_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](/lego/dns/#configuration-and-credentials). + + + + +## More information + +- [API documentation](https://api-docs.constellix.com) + + + + diff --git a/go.mod b/go.mod index a2b702d5..f2cf4a5b 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/cpu/goacmedns v0.0.1 github.com/dnsimple/dnsimple-go v0.30.0 github.com/exoscale/egoscale v0.18.1 + github.com/google/go-querystring v1.0.0 github.com/gophercloud/gophercloud v0.3.0 github.com/hashicorp/golang-lru v0.5.3 // indirect github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df diff --git a/go.sum b/go.sum index 205e4579..2dc8080d 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= diff --git a/providers/dns/constellix/constellix.go b/providers/dns/constellix/constellix.go new file mode 100644 index 00000000..356572c2 --- /dev/null +++ b/providers/dns/constellix/constellix.go @@ -0,0 +1,223 @@ +// Package constellix implements a DNS provider for solving the DNS-01 challenge using Constellix DNS. +package constellix + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-acme/lego/v3/challenge/dns01" + "github.com/go-acme/lego/v3/platform/config/env" + "github.com/go-acme/lego/v3/providers/dns/constellix/internal" +) + +// Config is used to configure the creation of the DNSProvider +type Config struct { + APIKey string + SecretKey string + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPClient *http.Client +} + +// NewDefaultConfig returns a default configuration for the DNSProvider +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt("CONSTELLIX_TTL", 300), + PropagationTimeout: env.GetOrDefaultSecond("CONSTELLIX_PROPAGATION_TIMEOUT", dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond("CONSTELLIX_POLLING_INTERVAL", dns01.DefaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond("CONSTELLIX_HTTP_TIMEOUT", 30*time.Second), + }, + } +} + +// DNSProvider is an implementation of the challenge.Provider interface. +type DNSProvider struct { + config *Config + client *internal.Client +} + +// NewDNSProvider returns a DNSProvider instance configured for Constellix. +// Credentials must be passed in the environment variables: +// CONSTELLIX_API_KEY and CONSTELLIX_SECRET_KEY. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get("CONSTELLIX_API_KEY", "CONSTELLIX_SECRET_KEY") + if err != nil { + return nil, fmt.Errorf("constellix: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values["CONSTELLIX_API_KEY"] + config.SecretKey = values["CONSTELLIX_SECRET_KEY"] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for Constellix. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("constellix: the configuration of the DNS provider is nil") + } + + if config.SecretKey == "" || config.APIKey == "" { + return nil, errors.New("constellix: incomplete credentials, missing secret key and/or API key") + } + + tr, err := internal.NewTokenTransport(config.APIKey, config.SecretKey) + if err != nil { + return nil, fmt.Errorf("constellix: %w", err) + } + + client := internal.NewClient(tr.Wrap(config.HTTPClient)) + + return &DNSProvider{config: config, client: client}, nil +} + +// Timeout returns the timeout and interval to use when checking for DNS propagation. +// Adjusting here to cope with spikes in propagation times. +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +// Present creates a TXT record using the specified parameters +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err) + } + + domainID, err := d.client.Domains.GetID(dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("constellix: failed to get domain ID: %w", err) + } + + records, err := d.client.TxtRecords.GetAll(domainID) + if err != nil { + return fmt.Errorf("constellix: failed to get TXT records: %w", err) + } + + recordName := getRecordName(fqdn, authZone) + + record := findRecords(records, recordName) + + // TXT record entry already existing + if record != nil { + if containsValue(record, value) { + return nil + } + + request := internal.RecordRequest{ + Name: record.Name, + TTL: record.TTL, + RoundRobin: append(record.RoundRobin, internal.RecordValue{Value: fmt.Sprintf(`"%s"`, value)}), + } + + _, err = d.client.TxtRecords.Update(domainID, record.ID, request) + if err != nil { + return fmt.Errorf("constellix: failed to update TXT records: %w", err) + } + return nil + } + + request := internal.RecordRequest{ + Name: recordName, + TTL: d.config.TTL, + RoundRobin: []internal.RecordValue{ + {Value: fmt.Sprintf(`"%s"`, value)}, + }, + } + + _, err = d.client.TxtRecords.Create(domainID, request) + if err != nil { + return fmt.Errorf("constellix: failed to create TXT record %s: %w", fqdn, err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return fmt.Errorf("constellix: could not find zone for domain %q and fqdn %q : %w", domain, fqdn, err) + } + + domainID, err := d.client.Domains.GetID(dns01.UnFqdn(authZone)) + if err != nil { + return fmt.Errorf("constellix: failed to get domain ID: %w", err) + } + + records, err := d.client.TxtRecords.GetAll(domainID) + if err != nil { + return fmt.Errorf("constellix: failed to get TXT records: %w", err) + } + + recordName := getRecordName(fqdn, authZone) + + record := findRecords(records, recordName) + if record == nil { + return nil + } + + if !containsValue(record, value) { + return nil + } + + // only 1 record value, the whole record must be deleted. + if len(record.Value) == 1 { + _, err = d.client.TxtRecords.Delete(domainID, record.ID) + if err != nil { + return fmt.Errorf("constellix: failed to delete TXT records: %w", err) + } + return nil + } + + request := internal.RecordRequest{ + Name: record.Name, + TTL: record.TTL, + } + + for _, val := range record.Value { + if val.Value != fmt.Sprintf(`"%s"`, value) { + request.RoundRobin = append(request.RoundRobin, val) + } + } + + _, err = d.client.TxtRecords.Update(domainID, record.ID, request) + if err != nil { + return fmt.Errorf("constellix: failed to update TXT records: %w", err) + } + + return nil +} + +func findRecords(records []internal.Record, name string) *internal.Record { + for _, r := range records { + if r.Name == name { + return &r + } + } + + return nil +} + +func containsValue(record *internal.Record, value string) bool { + for _, val := range record.Value { + if val.Value == fmt.Sprintf(`"%s"`, value) { + return true + } + } + + return false +} + +func getRecordName(fqdn, authZone string) string { + return fqdn[0 : len(fqdn)-len(authZone)-1] +} diff --git a/providers/dns/constellix/constellix.toml b/providers/dns/constellix/constellix.toml new file mode 100644 index 00000000..8ed2fdca --- /dev/null +++ b/providers/dns/constellix/constellix.toml @@ -0,0 +1,24 @@ +Name = "Constellix" +Description = '''''' +URL = "https://constellix.com" +Code = "constellix" +Since = "v0.3.4" + +Example = ''' +CONSTELLIX_API_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ +CONSTELLIX_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ +lego --dns constellix --domains my.domain.com --email my@email.com run +''' + +[Configuration] + [Configuration.Credentials] + CONSTELLIX_API_KEY = "User API key" + CONSTELLIX_SECRET_KEY = "User secret key" + [Configuration.Additional] + CONSTELLIX_POLLING_INTERVAL = "Time between DNS propagation check" + CONSTELLIX_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + CONSTELLIX_TTL = "The TTL of the TXT record used for the DNS challenge" + CONSTELLIX_HTTP_TIMEOUT = "API request timeout" + +[Links] + API = "https://api-docs.constellix.com" diff --git a/providers/dns/constellix/constellix_test.go b/providers/dns/constellix/constellix_test.go new file mode 100644 index 00000000..a94f9586 --- /dev/null +++ b/providers/dns/constellix/constellix_test.go @@ -0,0 +1,150 @@ +package constellix + +import ( + "testing" + "time" + + "github.com/go-acme/lego/v3/platform/tester" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest( + "CONSTELLIX_API_KEY", + "CONSTELLIX_SECRET_KEY"). + WithDomain("CONSTELLIX_DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + "CONSTELLIX_API_KEY": "123", + "CONSTELLIX_SECRET_KEY": "456", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + "CONSTELLIX_API_KEY": "", + "CONSTELLIX_SECRET_KEY": "", + }, + expected: "constellix: some credentials information are missing: CONSTELLIX_API_KEY,CONSTELLIX_SECRET_KEY", + }, + { + desc: "missing api key", + envVars: map[string]string{ + "CONSTELLIX_API_KEY": "", + "CONSTELLIX_SECRET_KEY": "api_password", + }, + expected: "constellix: some credentials information are missing: CONSTELLIX_API_KEY", + }, + { + desc: "missing secret key", + envVars: map[string]string{ + "CONSTELLIX_API_KEY": "api_username", + "CONSTELLIX_SECRET_KEY": "", + }, + expected: "constellix: some credentials information are missing: CONSTELLIX_SECRET_KEY", + }, + } + + 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 len(test.expected) == 0 { + 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 + expected string + apiKey string + secretKey string + }{ + { + desc: "success", + apiKey: "api_key", + secretKey: "api_secret", + }, + { + desc: "missing credentials", + expected: "constellix: incomplete credentials, missing secret key and/or API key", + }, + { + desc: "missing api key", + apiKey: "", + secretKey: "api_secret", + expected: "constellix: incomplete credentials, missing secret key and/or API key", + }, + { + desc: "missing secret key", + apiKey: "api_key", + secretKey: "", + expected: "constellix: incomplete credentials, missing secret key and/or API key", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIKey = test.apiKey + config.SecretKey = test.secretKey + + p, err := NewDNSProviderConfig(config) + + if len(test.expected) == 0 { + 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) + + time.Sleep(1 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} diff --git a/providers/dns/constellix/internal/auth.go b/providers/dns/constellix/internal/auth.go new file mode 100644 index 00000000..768b8b69 --- /dev/null +++ b/providers/dns/constellix/internal/auth.go @@ -0,0 +1,90 @@ +package internal + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "fmt" + "net/http" + "strconv" + "time" +) + +const securityTokenHeader = "x-cns-security-token" + +// TokenTransport HTTP transport for API authentication. +type TokenTransport struct { + apiKey string + secretKey string + + // Transport is the underlying HTTP transport to use when making requests. + // It will default to http.DefaultTransport if nil. + Transport http.RoundTripper +} + +// NewTokenTransport Creates a HTTP transport for API authentication. +func NewTokenTransport(apiKey, secretKey string) (*TokenTransport, error) { + if apiKey == "" { + return nil, fmt.Errorf("credentials missing: API key") + } + if secretKey == "" { + return nil, fmt.Errorf("credentials missing: secret key") + } + + return &TokenTransport{apiKey: apiKey, secretKey: secretKey}, nil +} + +// RoundTrip executes a single HTTP transaction +func (t *TokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + enrichedReq := &http.Request{} + *enrichedReq = *req + + enrichedReq.Header = make(http.Header, len(req.Header)) + for k, s := range req.Header { + enrichedReq.Header[k] = append([]string(nil), s...) + } + + if t.apiKey != "" && t.secretKey != "" { + securityToken := createCnsSecurityToken(t.apiKey, t.secretKey) + enrichedReq.Header.Add(securityTokenHeader, securityToken) + } + + return t.transport().RoundTrip(enrichedReq) +} + +func (t *TokenTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return http.DefaultTransport +} + +// Client Creates a new HTTP client +func (t *TokenTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +// Wrap Wrap a HTTP client Transport with the TokenTransport +func (t *TokenTransport) Wrap(client *http.Client) *http.Client { + backup := client.Transport + t.Transport = backup + client.Transport = t + + return client +} + +func createCnsSecurityToken(apiKey, secretKey string) string { + timestamp := time.Now().Round(time.Millisecond).UnixNano() / int64(time.Millisecond) + + hm := encodedHmac(timestamp, secretKey) + requestDate := strconv.FormatInt(timestamp, 10) + + return fmt.Sprintf("%s:%s:%s", apiKey, hm, requestDate) +} + +func encodedHmac(message int64, secret string) string { + h := hmac.New(sha1.New, []byte(secret)) + _, _ = h.Write([]byte(strconv.FormatInt(message, 10))) + + return base64.StdEncoding.EncodeToString(h.Sum(nil)) +} diff --git a/providers/dns/constellix/internal/auth_test.go b/providers/dns/constellix/internal/auth_test.go new file mode 100644 index 00000000..8dddd2b8 --- /dev/null +++ b/providers/dns/constellix/internal/auth_test.go @@ -0,0 +1,43 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTokenTransport_success(t *testing.T) { + apiKey := "api" + secretKey := "secret" + + transport, err := NewTokenTransport(apiKey, secretKey) + require.NoError(t, err) + assert.NotNil(t, transport) +} + +func TestNewTokenTransport_missing_credentials(t *testing.T) { + apiKey := "" + secretKey := "" + + transport, err := NewTokenTransport(apiKey, secretKey) + require.Error(t, err) + assert.Nil(t, transport) +} + +func TestTokenTransport_RoundTrip(t *testing.T) { + apiKey := "api" + secretKey := "secret" + + transport, err := NewTokenTransport(apiKey, secretKey) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + + resp, err := transport.RoundTrip(req) + require.NoError(t, err) + + assert.Regexp(t, `api:[^:]{28}:\d{13}`, resp.Request.Header.Get(securityTokenHeader)) +} diff --git a/providers/dns/constellix/internal/client.go b/providers/dns/constellix/internal/client.go new file mode 100644 index 00000000..ba0bf812 --- /dev/null +++ b/providers/dns/constellix/internal/client.go @@ -0,0 +1,107 @@ +package internal + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path" +) + +const ( + defaultBaseURL = "https://api.dns.constellix.com" + defaultVersion = "v1" +) + +// Client the Constellix client. +type Client struct { + BaseURL string + HTTPClient *http.Client + + common service // Reuse a single struct instead of allocating one for each service on the heap. + + // Services used for communicating with the API + Domains *DomainService + TxtRecords *TxtRecordService +} + +// NewClient Creates a Constellix client. +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + + client := &Client{ + BaseURL: defaultBaseURL, + HTTPClient: httpClient, + } + + client.common.client = client + client.Domains = (*DomainService)(&client.common) + client.TxtRecords = (*TxtRecordService)(&client.common) + + return client +} + +type service struct { + client *Client +} + +// do sends an API request and returns the API response. +func (c *Client) do(req *http.Request, v interface{}) error { + req.Header.Set("Content-Type", "application/json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + err = checkResponse(resp) + if err != nil { + return err + } + + raw, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read body: %w", err) + } + + if err = json.Unmarshal(raw, v); err != nil { + return fmt.Errorf("unmarshaling %T error: %w: %s", v, err, string(raw)) + } + + return nil +} + +func (c *Client) createEndpoint(fragment ...string) (string, error) { + baseURL, err := url.Parse(c.BaseURL) + if err != nil { + return "", err + } + + endpoint, err := baseURL.Parse(path.Join(fragment...)) + if err != nil { + return "", err + } + + return endpoint.String(), nil +} + +func checkResponse(resp *http.Response) error { + if resp.StatusCode == http.StatusOK { + return nil + } + + data, err := ioutil.ReadAll(resp.Body) + if err == nil && data != nil { + msg := APIError{} + if json.Unmarshal(data, &msg) != nil { + return fmt.Errorf("API error: status code: %d: %v", resp.StatusCode, string(data)) + } + return msg + } + + return fmt.Errorf("API error, status code: %d", resp.StatusCode) +} diff --git a/providers/dns/constellix/internal/domains.go b/providers/dns/constellix/internal/domains.go new file mode 100644 index 00000000..04bb1df1 --- /dev/null +++ b/providers/dns/constellix/internal/domains.go @@ -0,0 +1,81 @@ +package internal + +import ( + "fmt" + "net/http" + + querystring "github.com/google/go-querystring/query" +) + +// DomainService API access to Domain. +type DomainService service + +// GetID for a domain name. +func (s *DomainService) GetID(domainName string) (int64, error) { + params := &PaginationParameters{ + Offset: 0, + Max: 100, + Sort: "name", + Order: "asc", + } + + domains, err := s.GetAll(params) + if err != nil { + return 0, err + } + + for len(domains) > 0 { + for _, domain := range domains { + if domain.Name == domainName { + return domain.ID, nil + } + } + + if params.Max > len(domains) { + break + } + + params = &PaginationParameters{ + Offset: params.Max, + Max: 100, + Sort: "name", + Order: "asc", + } + + domains, err = s.GetAll(params) + if err != nil { + return 0, err + } + } + + return 0, fmt.Errorf("domain not found: %s", domainName) +} + +// GetAll domains. +func (s *DomainService) GetAll(params *PaginationParameters) ([]Domain, error) { + endpoint, err := s.client.createEndpoint(defaultVersion, "domains") + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if params != nil { + v, errQ := querystring.Values(params) + if errQ != nil { + return nil, errQ + } + req.URL.RawQuery = v.Encode() + } + + var domains []Domain + err = s.client.do(req, &domains) + if err != nil { + return nil, err + } + + return domains, nil +} diff --git a/providers/dns/constellix/internal/domains_test.go b/providers/dns/constellix/internal/domains_test.go new file mode 100644 index 00000000..311f00af --- /dev/null +++ b/providers/dns/constellix/internal/domains_test.go @@ -0,0 +1,86 @@ +package internal + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupAPIMock() (*Client, *http.ServeMux, func()) { + handler := http.NewServeMux() + svr := httptest.NewServer(handler) + + client := NewClient(nil) + client.BaseURL = svr.URL + + return client, handler, svr.Close +} + +func TestDomainService_GetAll(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/domains-01.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + data, err := client.Domains.GetAll(nil) + require.NoError(t, err) + + expected := []Domain{ + {ID: 273302, Name: "lego.wtf", TypeID: 1, Version: 9, Status: "ACTIVE"}, + } + + assert.Equal(t, expected, data) +} + +func TestDomainService_GetID(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/domains-02.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + data, err := client.Domains.GetID("ddd.wtf") + require.NoError(t, err) + + assert.EqualValues(t, 273304, data) +} diff --git a/providers/dns/constellix/internal/fixtures/domains-01.json b/providers/dns/constellix/internal/fixtures/domains-01.json new file mode 100644 index 00000000..5d018a39 --- /dev/null +++ b/providers/dns/constellix/internal/fixtures/domains-01.json @@ -0,0 +1,37 @@ +[ + { + "id": 273302, + "name": "lego.wtf", + "soa": { + "primaryNameserver": "ns11.constellix.com.", + "email": "dns.constellix.com.", + "ttl": 86400, + "serial": 2015010110, + "refresh": 43200, + "retry": 3600, + "expire": 1209600, + "negCache": 180 + }, + "createdTs": "2020-02-04T22:42:10Z", + "modifiedTs": "2020-02-05T09:23:17Z", + "typeId": 1, + "domainTags": [], + "folder": null, + "hasGtdRegions": false, + "hasGeoIP": false, + "nameserverGroup": 1, + "nameservers": [ + "ns11.constellix.com.", + "ns21.constellix.com.", + "ns31.constellix.com.", + "ns41.constellix.net.", + "ns51.constellix.net.", + "ns61.constellix.net." + ], + "note": "", + "version": 9, + "status": "ACTIVE", + "tags": null, + "contactIds": [] + } +] diff --git a/providers/dns/constellix/internal/fixtures/domains-02.json b/providers/dns/constellix/internal/fixtures/domains-02.json new file mode 100644 index 00000000..5ff2ad41 --- /dev/null +++ b/providers/dns/constellix/internal/fixtures/domains-02.json @@ -0,0 +1,142 @@ +[ + { + "id": 273301, + "name": "aaa.wtf", + "soa": { + "primaryNameserver": "ns11.constellix.com.", + "email": "dns.constellix.com.", + "ttl": 86400, + "serial": 2015010110, + "refresh": 43200, + "retry": 3600, + "expire": 1209600, + "negCache": 180 + }, + "createdTs": "2020-02-04T22:42:10Z", + "modifiedTs": "2020-02-05T09:23:17Z", + "typeId": 1, + "domainTags": [], + "folder": null, + "hasGtdRegions": false, + "hasGeoIP": false, + "nameserverGroup": 1, + "nameservers": [ + "ns11.constellix.com.", + "ns21.constellix.com.", + "ns31.constellix.com.", + "ns41.constellix.net.", + "ns51.constellix.net.", + "ns61.constellix.net." + ], + "note": "", + "version": 9, + "status": "ACTIVE", + "tags": null, + "contactIds": [] + }, + { + "id": 273302, + "name": "bbb.wtf", + "soa": { + "primaryNameserver": "ns11.constellix.com.", + "email": "dns.constellix.com.", + "ttl": 86400, + "serial": 2015010110, + "refresh": 43200, + "retry": 3600, + "expire": 1209600, + "negCache": 180 + }, + "createdTs": "2020-02-04T22:42:10Z", + "modifiedTs": "2020-02-05T09:23:17Z", + "typeId": 1, + "domainTags": [], + "folder": null, + "hasGtdRegions": false, + "hasGeoIP": false, + "nameserverGroup": 1, + "nameservers": [ + "ns11.constellix.com.", + "ns21.constellix.com.", + "ns31.constellix.com.", + "ns41.constellix.net.", + "ns51.constellix.net.", + "ns61.constellix.net." + ], + "note": "", + "version": 9, + "status": "ACTIVE", + "tags": null, + "contactIds": [] + }, + { + "id": 273303, + "name": "ccc.wtf", + "soa": { + "primaryNameserver": "ns11.constellix.com.", + "email": "dns.constellix.com.", + "ttl": 86400, + "serial": 2015010110, + "refresh": 43200, + "retry": 3600, + "expire": 1209600, + "negCache": 180 + }, + "createdTs": "2020-02-04T22:42:10Z", + "modifiedTs": "2020-02-05T09:23:17Z", + "typeId": 1, + "domainTags": [], + "folder": null, + "hasGtdRegions": false, + "hasGeoIP": false, + "nameserverGroup": 1, + "nameservers": [ + "ns11.constellix.com.", + "ns21.constellix.com.", + "ns31.constellix.com.", + "ns41.constellix.net.", + "ns51.constellix.net.", + "ns61.constellix.net." + ], + "note": "", + "version": 9, + "status": "ACTIVE", + "tags": null, + "contactIds": [] + }, + { + "id": 273304, + "name": "ddd.wtf", + "soa": { + "primaryNameserver": "ns11.constellix.com.", + "email": "dns.constellix.com.", + "ttl": 86400, + "serial": 2015010110, + "refresh": 43200, + "retry": 3600, + "expire": 1209600, + "negCache": 180 + }, + "createdTs": "2020-02-04T22:42:10Z", + "modifiedTs": "2020-02-05T09:23:17Z", + "typeId": 1, + "domainTags": [], + "folder": null, + "hasGtdRegions": false, + "hasGeoIP": false, + "nameserverGroup": 1, + "nameservers": [ + "ns11.constellix.com.", + "ns21.constellix.com.", + "ns31.constellix.com.", + "ns41.constellix.net.", + "ns51.constellix.net.", + "ns61.constellix.net." + ], + "note": "", + "version": 9, + "status": "ACTIVE", + "tags": null, + "contactIds": [] + } +] diff --git a/providers/dns/constellix/internal/fixtures/records-01.json b/providers/dns/constellix/internal/fixtures/records-01.json new file mode 100644 index 00000000..54f27a9e --- /dev/null +++ b/providers/dns/constellix/internal/fixtures/records-01.json @@ -0,0 +1,25 @@ +[ + { + "id": 3557066, + "type": "TXT", + "recordType": "txt", + "name": "test", + "recordOption": "roundRobin", + "ttl": 300, + "gtdRegion": 1, + "parentId": 273302, + "parent": "domain", + "source": "Domain", + "modifiedTs": 1580908547865, + "value": [ + { + "value": "\"test\"" + } + ], + "roundRobin": [ + { + "value": "\"test\"" + } + ] + } +] \ No newline at end of file diff --git a/providers/dns/constellix/internal/fixtures/records-02.json b/providers/dns/constellix/internal/fixtures/records-02.json new file mode 100644 index 00000000..a32cba1d --- /dev/null +++ b/providers/dns/constellix/internal/fixtures/records-02.json @@ -0,0 +1,23 @@ +{ + "id": 3557066, + "type": "TXT", + "recordType": "txt", + "name": "test", + "recordOption": "roundRobin", + "ttl": 300, + "gtdRegion": 1, + "parentId": 273302, + "parent": "domain", + "source": "Domain", + "modifiedTs": 1580908547863, + "value": [ + { + "value": "\"test\"" + } + ], + "roundRobin": [ + { + "value": "\"test\"" + } + ] +} diff --git a/providers/dns/constellix/internal/model.go b/providers/dns/constellix/internal/model.go new file mode 100644 index 00000000..955e8a66 --- /dev/null +++ b/providers/dns/constellix/internal/model.go @@ -0,0 +1,72 @@ +package internal + +import ( + "strings" +) + +// APIError is the representation of an API error. +type APIError struct { + Errors []string `json:"errors"` +} + +func (a APIError) Error() string { + return strings.Join(a.Errors, ": ") +} + +// SuccessMessage is the representation of a success message. +type SuccessMessage struct { + Success string `json:"success"` +} + +// RecordRequest is the representation of a request's record. +type RecordRequest struct { + Name string `json:"name"` + TTL int `json:"ttl,omitempty"` + RoundRobin []RecordValue `json:"roundRobin,omitempty"` +} + +// RecordValue is the representation of a record's value. +type RecordValue struct { + Value string `json:"value,omitempty"` + DisableFlag bool `json:"disableFlag,omitempty"` // only for the response +} + +// Record is the representation of a record. +type Record struct { + ID int64 `json:"id"` + Type string `json:"type"` + RecordType string `json:"recordType"` + Name string `json:"name"` + RecordOption string `json:"recordOption,omitempty"` + NoAnswer bool `json:"noAnswer,omitempty"` + Note string `json:"note,omitempty"` + TTL int `json:"ttl,omitempty"` + GtdRegion int `json:"gtdRegion,omitempty"` + ParentID int `json:"parentId,omitempty"` + Parent string `json:"parent,omitempty"` + Source string `json:"source,omitempty"` + ModifiedTs int64 `json:"modifiedTs,omitempty"` + Value []RecordValue `json:"value,omitempty"` + RoundRobin []RecordValue `json:"roundRobin,omitempty"` +} + +// Domain is the representation of a domain. +type Domain struct { + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + TypeID int64 `json:"typeId,omitempty"` + Version int64 `json:"version,omitempty"` + Status string `json:"status,omitempty"` +} + +// PaginationParameters is pagination parameters. +type PaginationParameters struct { + // Offset retrieves a subset of records starting with the offset value. + Offset int `url:"offset"` + // Max retrieves maximum number of dataset. + Max int `url:"max"` + // Sort on the basis of given property name. + Sort string `url:"sort"` + // Order Sort order. Possible values are asc / desc. + Order string `url:"order"` +} diff --git a/providers/dns/constellix/internal/txtrecords.go b/providers/dns/constellix/internal/txtrecords.go new file mode 100644 index 00000000..9968f589 --- /dev/null +++ b/providers/dns/constellix/internal/txtrecords.go @@ -0,0 +1,132 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strconv" +) + +// TxtRecordService API access to Record. +type TxtRecordService service + +// Create a TXT record. +// https://api-docs.constellix.com/?version=latest#22e24d5b-9ec0-49a7-b2b0-5ff0a28e71be +func (s *TxtRecordService) Create(domainID int64, record RecordRequest) ([]Record, error) { + body, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("failed to marshall request body: %w", err) + } + + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt") + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var records []Record + err = s.client.do(req, &records) + if err != nil { + return nil, err + } + + return records, nil +} + +// GetAll TXT records. +// https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2 +func (s *TxtRecordService) GetAll(domainID int64) ([]Record, error) { + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt") + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var records []Record + err = s.client.do(req, &records) + if err != nil { + return nil, err + } + + return records, nil +} + +// Get a TXT record. +// https://api-docs.constellix.com/?version=latest#e7103c53-2ad8-4bc8-b5b3-4c22c4b571b2 +func (s *TxtRecordService) Get(domainID, recordID int64) (*Record, error) { + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var records Record + err = s.client.do(req, &records) + if err != nil { + return nil, err + } + + return &records, nil +} + +// Update a TXT record. +// https://api-docs.constellix.com/?version=latest#d4e9ab2e-fac0-45a6-b0e4-cf62a2d2e3da +func (s *TxtRecordService) Update(domainID, recordID int64, record RecordRequest) (*SuccessMessage, error) { + body, err := json.Marshal(record) + if err != nil { + return nil, fmt.Errorf("failed to marshall request body: %w", err) + } + + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var msg SuccessMessage + err = s.client.do(req, &msg) + if err != nil { + return nil, err + } + + return &msg, nil +} + +// Delete a TXT record. +// https://api-docs.constellix.com/?version=latest#135947f7-d6c8-481a-83c7-4d387b0bdf9e +func (s *TxtRecordService) Delete(domainID, recordID int64) (*SuccessMessage, error) { + endpoint, err := s.client.createEndpoint(defaultVersion, "domains", strconv.FormatInt(domainID, 10), "records", "txt", strconv.FormatInt(recordID, 10)) + if err != nil { + return nil, fmt.Errorf("failed to create request endpoint: %w", err) + } + + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var msg *SuccessMessage + err = s.client.do(req, &msg) + if err != nil { + return nil, err + } + + return msg, nil +} diff --git a/providers/dns/constellix/internal/txtrecords_test.go b/providers/dns/constellix/internal/txtrecords_test.go new file mode 100644 index 00000000..460169dd --- /dev/null +++ b/providers/dns/constellix/internal/txtrecords_test.go @@ -0,0 +1,182 @@ +package internal + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTxtRecordService_Create(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/records-01.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + records, err := client.TxtRecords.Create(12345, RecordRequest{}) + require.NoError(t, err) + + recordsJSON, err := json.Marshal(records) + require.NoError(t, err) + + expectedContent, err := ioutil.ReadFile("./fixtures/records-01.json") + require.NoError(t, err) + + assert.JSONEq(t, string(expectedContent), string(recordsJSON)) +} + +func TestTxtRecordService_GetAll(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains/12345/records/txt", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/records-01.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + records, err := client.TxtRecords.GetAll(12345) + require.NoError(t, err) + + recordsJSON, err := json.Marshal(records) + require.NoError(t, err) + + expectedContent, err := ioutil.ReadFile("./fixtures/records-01.json") + require.NoError(t, err) + + assert.JSONEq(t, string(expectedContent), string(recordsJSON)) +} + +func TestTxtRecordService_Get(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + file, err := os.Open("./fixtures/records-02.json") + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + _, err = io.Copy(rw, file) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + record, err := client.TxtRecords.Get(12345, 6789) + require.NoError(t, err) + + expected := &Record{ + ID: 3557066, + Type: "TXT", + RecordType: "txt", + Name: "test", + TTL: 300, + RecordOption: "roundRobin", + GtdRegion: 1, + ParentID: 273302, + Parent: "domain", + Source: "Domain", + ModifiedTs: 1580908547863, + Value: []RecordValue{{ + Value: `"test"`, + }}, + RoundRobin: []RecordValue{{ + Value: `"test"`, + }}, + } + assert.Equal(t, expected, record) +} + +func TestTxtRecordService_Update(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPut { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + _, err := rw.Write([]byte(`{"success":"Record updated successfully"}`)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + msg, err := client.TxtRecords.Update(12345, 6789, RecordRequest{}) + require.NoError(t, err) + + expected := &SuccessMessage{Success: "Record updated successfully"} + assert.Equal(t, expected, msg) +} + +func TestTxtRecordService_Delete(t *testing.T) { + client, handler, tearDown := setupAPIMock() + defer tearDown() + + handler.HandleFunc("/v1/domains/12345/records/txt/6789", func(rw http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodDelete { + http.Error(rw, "invalid method: "+req.Method, http.StatusBadRequest) + return + } + + _, err := rw.Write([]byte(`{"success":"Record deleted successfully"}`)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + }) + + msg, err := client.TxtRecords.Delete(12345, 6789) + require.NoError(t, err) + + expected := &SuccessMessage{Success: "Record deleted successfully"} + assert.Equal(t, expected, msg) +} diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index a095c3c2..7166b2fd 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -17,6 +17,7 @@ import ( "github.com/go-acme/lego/v3/providers/dns/cloudns" "github.com/go-acme/lego/v3/providers/dns/cloudxns" "github.com/go-acme/lego/v3/providers/dns/conoha" + "github.com/go-acme/lego/v3/providers/dns/constellix" "github.com/go-acme/lego/v3/providers/dns/designate" "github.com/go-acme/lego/v3/providers/dns/digitalocean" "github.com/go-acme/lego/v3/providers/dns/dnsimple" @@ -98,6 +99,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return cloudxns.NewDNSProvider() case "conoha": return conoha.NewDNSProvider() + case "constellix": + return constellix.NewDNSProvider() case "designate": return designate.NewDNSProvider() case "digitalocean":