From 9b5205ca44f606cf6ce18a8aafdd8defafc2521f Mon Sep 17 00:00:00 2001 From: maxim Date: Thu, 22 Jul 2021 12:32:55 +0300 Subject: [PATCH 1/4] + add G-Core Labs as new DNS provide --- README.md | 32 +- cmd/zz_gen_cmd_dnshelp.go | 22 ++ docs/content/dns/zz_gen_gcore.md | 63 ++++ providers/dns/dns_providers.go | 3 + providers/dns/gcore/gcore.go | 117 +++++++ providers/dns/gcore/gcore.toml | 23 ++ providers/dns/gcore/gcore_test.go | 199 ++++++++++++ providers/dns/gcore/internal/client.go | 206 ++++++++++++ providers/dns/gcore/internal/client_dto.go | 14 + providers/dns/gcore/internal/client_test.go | 337 ++++++++++++++++++++ 10 files changed, 1000 insertions(+), 16 deletions(-) create mode 100644 docs/content/dns/zz_gen_gcore.md create mode 100644 providers/dns/gcore/gcore.go create mode 100644 providers/dns/gcore/gcore.toml create mode 100644 providers/dns/gcore/gcore_test.go create mode 100644 providers/dns/gcore/internal/client.go create mode 100644 providers/dns/gcore/internal/client_dto.go create mode 100644 providers/dns/gcore/internal/client_test.go diff --git a/README.md b/README.md index 67e6d6002c..c4c5ee804f 100644 --- a/README.md +++ b/README.md @@ -54,22 +54,22 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [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/) | [Domeneshop](https://go-acme.github.io/lego/dns/domeneshop/) | [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/) | [Dynu](https://go-acme.github.io/lego/dns/dynu/) | [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/) | [freemyip.com](https://go-acme.github.io/lego/dns/freemyip/) | [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/) | [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | -| [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [Hosttech](https://go-acme.github.io/lego/dns/hosttech/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) | -| [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Infoblox](https://go-acme.github.io/lego/dns/infoblox/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | -| [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | [Joker](https://go-acme.github.io/lego/dns/joker/) | -| [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | [Loopia](https://go-acme.github.io/lego/dns/loopia/) | -| [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | -| [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/) | -| [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | [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/) | [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/) | [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/) | -| [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | -| [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | [Yandex](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/) | | | +| [External program](https://go-acme.github.io/lego/dns/exec/) | [freemyip.com](https://go-acme.github.io/lego/dns/freemyip/) | [G-Core Labs](https://go-acme.github.io/lego/dns/gcore/) | [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/) | +| [Hetzner](https://go-acme.github.io/lego/dns/hetzner/) | [Hosting.de](https://go-acme.github.io/lego/dns/hostingde/) | [Hosttech](https://go-acme.github.io/lego/dns/hosttech/) | [HTTP request](https://go-acme.github.io/lego/dns/httpreq/) | +| [Hurricane Electric DNS](https://go-acme.github.io/lego/dns/hurricane/) | [HyperOne](https://go-acme.github.io/lego/dns/hyperone/) | [Infoblox](https://go-acme.github.io/lego/dns/infoblox/) | [Infomaniak](https://go-acme.github.io/lego/dns/infomaniak/) | +| [Internet Initiative Japan](https://go-acme.github.io/lego/dns/iij/) | [Internet.bs](https://go-acme.github.io/lego/dns/internetbs/) | [INWX](https://go-acme.github.io/lego/dns/inwx/) | [Ionos](https://go-acme.github.io/lego/dns/ionos/) | +| [Joker](https://go-acme.github.io/lego/dns/joker/) | [Joohoi's ACME-DNS](https://go-acme.github.io/lego/dns/acme-dns/) | [Linode (v4)](https://go-acme.github.io/lego/dns/linode/) | [Liquid Web](https://go-acme.github.io/lego/dns/liquidweb/) | +| [Loopia](https://go-acme.github.io/lego/dns/loopia/) | [LuaDNS](https://go-acme.github.io/lego/dns/luadns/) | [Manual](https://go-acme.github.io/lego/dns/manual/) | [MyDNS.jp](https://go-acme.github.io/lego/dns/mydnsjp/) | +| [MythicBeasts](https://go-acme.github.io/lego/dns/mythicbeasts/) | [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/) | [Netlify](https://go-acme.github.io/lego/dns/netlify/) | [NIFCloud](https://go-acme.github.io/lego/dns/nifcloud/) | [Njalla](https://go-acme.github.io/lego/dns/njalla/) | +| [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/) | +| [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/) | [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/) | [TransIP](https://go-acme.github.io/lego/dns/transip/) | [VegaDNS](https://go-acme.github.io/lego/dns/vegadns/) | [Versio.[nl/eu/uk]](https://go-acme.github.io/lego/dns/versio/) | +| [VinylDNS](https://go-acme.github.io/lego/dns/vinyldns/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [WEDOS](https://go-acme.github.io/lego/dns/wedos/) | +| [Yandex](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 cd426537f2..86bac468e1 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -50,6 +50,7 @@ func allDNSCodes() string { "gandi", "gandiv5", "gcloud", + "gcore", "glesys", "godaddy", "hetzner", @@ -881,6 +882,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gcloud`) + case "gcore": + // generated from: providers/dns/gcore/gcore.toml + ew.writeln(`Configuration for G-Core Labs.`) + ew.writeln(`Code: 'gcore'`) + ew.writeln(`Since: 'v4.5.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "GCORE_PERMANENT_API_TOKEN": Permanent API tokene (https://gcorelabs.com/blog/permanent-api-token-explained/)`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "GCORE_API_URL": G-Cole Labs DNS API URL (default: http://dnsapi.gcorelabs.com/)`) + ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout`) + ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "GCORE_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/gcore`) + case "glesys": // generated from: providers/dns/glesys/glesys.toml ew.writeln(`Configuration for Glesys.`) diff --git a/docs/content/dns/zz_gen_gcore.md b/docs/content/dns/zz_gen_gcore.md new file mode 100644 index 0000000000..af47d47e5b --- /dev/null +++ b/docs/content/dns/zz_gen_gcore.md @@ -0,0 +1,63 @@ +--- +title: "G-Core Labs" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: gcore +--- + + + + + +Since: v4.5.0 + +Configuration for [G-Core Labs](https://gcorelabs.com/dns/). + + + + +- Code: `gcore` + +Here is an example bash command using the G-Core Labs provider: + +```bash +GCORE_PERMANENT_API_TOKEN= \ +lego --email myemail@example.com --dns gcore --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `GCORE_PERMANENT_API_TOKEN` | Permanent API tokene (https://gcorelabs.com/blog/permanent-api-token-explained/) | + +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 | +|--------------------------------|-------------| +| `GCORE_API_URL` | G-Cole Labs DNS API URL (default: http://dnsapi.gcorelabs.com/) | +| `GCORE_HTTP_TIMEOUT` | API request timeout | +| `GCORE_POLLING_INTERVAL` | Time between DNS propagation check | +| `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `GCORE_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://dnsapi.gcorelabs.com/docs#tag/zonesV2) + + + + diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index 51fd0c4b73..e73bc85fc6 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -41,6 +41,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/gandi" "github.com/go-acme/lego/v4/providers/dns/gandiv5" "github.com/go-acme/lego/v4/providers/dns/gcloud" + "github.com/go-acme/lego/v4/providers/dns/gcore" "github.com/go-acme/lego/v4/providers/dns/glesys" "github.com/go-acme/lego/v4/providers/dns/godaddy" "github.com/go-acme/lego/v4/providers/dns/hetzner" @@ -291,6 +292,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return zoneee.NewDNSProvider() case "zonomi": return zonomi.NewDNSProvider() + case gcore.ProviderCode: + return gcore.NewDNSProvider() default: return nil, fmt.Errorf("unrecognized DNS provider: %s", name) } diff --git a/providers/dns/gcore/gcore.go b/providers/dns/gcore/gcore.go new file mode 100644 index 0000000000..092b621d59 --- /dev/null +++ b/providers/dns/gcore/gcore.go @@ -0,0 +1,117 @@ +package gcore + +import ( + "context" + "fmt" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/go-acme/lego/v4/providers/dns/gcore/internal" +) + +const ( + // ProviderCode a value for cli dns flag. + ProviderCode = "gcore" + + envNamespace = "GCORE_" + envAPIUrl = envNamespace + "_API_URL" + envPermanentToken = envNamespace + "PERMANENT_API_TOKEN" + envTTL = envNamespace + "TTL" + envPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + envPollingInterval = envNamespace + "POLLING_INTERVAL" + envHTTPTimeout = envNamespace + "HTTP_TIMEOUT" + + defaultPropagationTimeout = 360 * time.Second + defaultPollingInterval = 20 * time.Second +) + +type ( + // TXTRecordManager contract for API client. + TXTRecordManager interface { + AddTXTRecord(ctx context.Context, fqdn, value string, ttl int) error + RemoveTXTRecord(ctx context.Context, fqdn, value string) error + } + // Config for DNSProvider. + Config struct { + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration + } + // DNSProviderOpt for constructor of DNSProvider. + DNSProviderOpt func(*DNSProvider) + // DNSProvider an implementation of challenge.Provider contract. + DNSProvider struct { + Config + Client TXTRecordManager + } +) + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() Config { + return Config{ + TTL: env.GetOrDefaultInt(envTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(envPropagationTimeout, defaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(envPollingInterval, defaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(envHTTPTimeout, defaultPropagationTimeout), + } +} + +// NewDNSProvider returns an instance of DNSProvider configured for G-Core Labs DNS API. +func NewDNSProvider(opts ...DNSProviderOpt) (*DNSProvider, error) { + values, err := env.Get(envPermanentToken) + if err != nil { + return nil, fmt.Errorf("%s: required env vars: %w", ProviderCode, err) + } + cfg := NewDefaultConfig() + p := &DNSProvider{ + Config: cfg, + Client: internal.NewClient( + values[envPermanentToken], + func(client *internal.Client) { + client.HTTPClient.Timeout = cfg.HTTPTimeout + }, + func(client *internal.Client) { + url := env.GetOrDefaultString(envAPIUrl, "") + if url != "" { + client.BaseURL = url + } + }, + ), + } + for _, op := range opts { + op(p) + } + return p, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + ctx, cancel := context.WithTimeout(context.Background(), d.Config.PropagationTimeout) + defer cancel() + err := d.Client.AddTXTRecord(ctx, fqdn, value, d.Config.TTL) + if err != nil { + return fmt.Errorf("add txt record: %w", err) + } + return nil +} + +// CleanUp removes the record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + ctx, cancel := context.WithTimeout(context.Background(), d.Config.PropagationTimeout) + defer cancel() + err := d.Client.RemoveTXTRecord(ctx, fqdn, value) + if err != nil { + return fmt.Errorf("remove txt record: %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 (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.Config.PropagationTimeout, d.Config.PollingInterval +} diff --git a/providers/dns/gcore/gcore.toml b/providers/dns/gcore/gcore.toml new file mode 100644 index 0000000000..c1375e9321 --- /dev/null +++ b/providers/dns/gcore/gcore.toml @@ -0,0 +1,23 @@ +Name = "G-Core Labs" +Description = '''''' +URL = "https://gcorelabs.com/dns/" +Code = "gcore" +Since = "v4.5.0" + +Example = ''' +GCORE_PERMANENT_API_TOKEN= \ +lego --email myemail@example.com --dns gcore --domains my.example.org run +''' + +[Configuration] +[Configuration.Credentials] +GCORE_PERMANENT_API_TOKEN = "Permanent API tokene (https://gcorelabs.com/blog/permanent-api-token-explained/)" +[Configuration.Additional] +GCORE_API_URL = "G-Cole Labs DNS API URL (default: http://dnsapi.gcorelabs.com/)" +GCORE_POLLING_INTERVAL = "Time between DNS propagation check" +GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" +GCORE_HTTP_TIMEOUT = "API request timeout" +GCORE_TTL = "The TTL of the TXT record used for the DNS challenge" + +[Links] +API = "https://dnsapi.gcorelabs.com/docs#tag/zonesV2" diff --git a/providers/dns/gcore/gcore_test.go b/providers/dns/gcore/gcore_test.go new file mode 100644 index 0000000000..8e991c3021 --- /dev/null +++ b/providers/dns/gcore/gcore_test.go @@ -0,0 +1,199 @@ +package gcore + +import ( + "context" + "fmt" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest(envPermanentToken).WithDomain(envNamespace + "DOMAIN") + +type mockClient struct{} + +func (m mockClient) AddTXTRecord(ctx context.Context, fqdn, value string, ttl int) error { + if strings.Contains(fqdn, "err") { + return fmt.Errorf("err") + } + return nil +} + +func (m mockClient) RemoveTXTRecord(ctx context.Context, fqdn, value string) error { + if strings.Contains(fqdn, "err") { + return fmt.Errorf("err") + } + return nil +} + +func TestNewDefaultConfig(t *testing.T) { + tests := []struct { + name string + exec func() + want Config + }{ + { + name: "default", + exec: func() {}, + want: Config{ + PropagationTimeout: defaultPropagationTimeout, + PollingInterval: defaultPollingInterval, + TTL: dns01.DefaultTTL, + HTTPTimeout: defaultPropagationTimeout, + }, + }, + { + name: "custom", + exec: func() { + _ = os.Setenv(envTTL, fmt.Sprintf("%d", 10)) + _ = os.Setenv(envHTTPTimeout, fmt.Sprintf("%d", 1)) + _ = os.Setenv(envPollingInterval, fmt.Sprintf("%d", 4)) + _ = os.Setenv(envPropagationTimeout, fmt.Sprintf("%d", 6)) + }, + want: Config{ + PropagationTimeout: 6 * time.Second, + PollingInterval: 4 * time.Second, + TTL: 10, + HTTPTimeout: time.Second, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.exec() + if got := NewDefaultConfig(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewDefaultConfig() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDNSProvider_Present(t *testing.T) { + type args struct { + domain string + token string + keyAuth string + } + tests := []struct { + name string + provider *DNSProvider + args args + wantErr bool + }{ + { + name: "success", + provider: &DNSProvider{ + Config: NewDefaultConfig(), + Client: mockClient{}, + }, + args: args{ + domain: "any", + token: "", + keyAuth: "", + }, + wantErr: false, + }, + { + name: "error", + provider: &DNSProvider{ + Config: NewDefaultConfig(), + Client: mockClient{}, + }, + args: args{ + domain: "err", + token: "", + keyAuth: "", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := tt.provider + if err := d.Present(tt.args.domain, tt.args.token, tt.args.keyAuth); (err != nil) != tt.wantErr { + t.Errorf("Present() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDNSProvider_CleanUp(t *testing.T) { + type args struct { + domain string + token string + keyAuth string + } + tests := []struct { + name string + provider *DNSProvider + args args + wantErr bool + }{ + { + name: "success", + provider: &DNSProvider{ + Config: NewDefaultConfig(), + Client: mockClient{}, + }, + args: args{ + domain: "any", + token: "", + keyAuth: "", + }, + wantErr: false, + }, + { + name: "error", + provider: &DNSProvider{ + Config: NewDefaultConfig(), + Client: mockClient{}, + }, + args: args{ + domain: "err", + token: "", + keyAuth: "", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := tt.provider + if err := d.CleanUp(tt.args.domain, tt.args.token, tt.args.keyAuth); (err != nil) != tt.wantErr { + t.Errorf("CleanUp() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +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) +} diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/gcore/internal/client.go new file mode 100644 index 0000000000..35f9c0d585 --- /dev/null +++ b/providers/dns/gcore/internal/client.go @@ -0,0 +1,206 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +const ( + defaultBaseURL = "https://dnsapi.gcorelabs.com" + tokenHeader = "APIKey" + recordType = "TXT" +) + +type ( + // ResponseErr representation. + ResponseErr string + // WrongStatusErr representation. + WrongStatusErr int + // ClientOpt for constructor of Client. + ClientOpt func(*Client) + // Client for DNS API. + Client struct { + HTTPClient *http.Client + BaseURL string + Token string + } +) + +// Error implementation of error contract. +func (r ResponseErr) Error() string { + return string(r) +} + +// Error implementation of error contract. +func (r WrongStatusErr) Error() string { + return fmt.Sprintf("wrong status = %d", int(r)) +} + +// Status info from response. +func (r WrongStatusErr) Status() int { + return int(r) +} + +// NewClient constructor of Client. +func NewClient(token string, opts ...ClientOpt) *Client { + cl := &Client{Token: token, BaseURL: defaultBaseURL, HTTPClient: &http.Client{}} + for _, op := range opts { + op(cl) + } + return cl +} + +// AddTXTRecord to DNS. +func (c *Client) AddTXTRecord(ctx context.Context, fqdn, value string, ttl int) error { + zone, err := c.findZone(ctx, fqdn) + if err != nil { + return fmt.Errorf("find zone: %w", err) + } + rrset := strings.TrimRight(fqdn, ".") + method := http.MethodPost + resourceRecords := []resourceRecord{{Content: []string{value}}} + txt, err := c.zoneTxtRecords(ctx, zone, rrset) + if err == nil && len(txt.ResourceRecords) > 0 { + method = http.MethodPut + resourceRecords = append(resourceRecords, txt.ResourceRecords...) + } + err = c.request( + ctx, + method, + fmt.Sprintf("v2/zones/%s/%s/%s", zone, rrset, recordType), + zoneRecord{ + TTL: ttl, + ResourceRecords: resourceRecords, + }, + nil, + ) + if err != nil { + return fmt.Errorf("add record request: %w", err) + } + return nil +} + +// RemoveTXTRecord from DNS. +func (c *Client) RemoveTXTRecord(ctx context.Context, fqdn, _ string) error { + zone, err := c.findZone(ctx, fqdn) + if err != nil { + return fmt.Errorf("find zone: %w", err) + } + err = c.request( + ctx, + http.MethodDelete, + fmt.Sprintf("v2/zones/%s/%s/%s", zone, fqdn, recordType), + nil, + nil, + ) + if err != nil { + // Support DELETE idempotence https://developer.mozilla.org/en-US/docs/Glossary/Idempotent + if statusErr := new(WrongStatusErr); errors.As(err, statusErr) && + statusErr.Status() == http.StatusNotFound { + return nil + } + return fmt.Errorf("delete record request: %w", err) + } + return nil +} + +func (c *Client) findZone(ctx context.Context, fqdn string) (dnsZone string, err error) { + possibleZones := extractAllZones(fqdn) + for _, zone := range possibleZones { + dnsZone, err = c.zone(ctx, zone) + if err == nil { + return dnsZone, nil + } + } + return "", fmt.Errorf("zone not found: %w", err) +} + +func (c *Client) zoneTxtRecords(ctx context.Context, zone, rrset string) (result zoneRecord, err error) { + err = c.request( + ctx, + http.MethodGet, + fmt.Sprintf("v2/zones/%s/%s/%s", zone, rrset, recordType), + nil, + &result, + ) + if err != nil { + return zoneRecord{}, fmt.Errorf("get zone txt records %s -> %s: %w", zone, rrset, err) + } + return result, nil +} + +func (c *Client) zone(ctx context.Context, zone string) (string, error) { + response := zoneResponse{} + err := c.request( + ctx, + http.MethodGet, + fmt.Sprintf("v2/zones/%s", zone), + nil, + &response, + ) + if err != nil { + return "", fmt.Errorf("get zone %s: %w", zone, err) + } + return response.Name, nil +} + +func (c *Client) requestURL(path string) string { + return strings.TrimRight(c.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +} + +func (c *Client) request(ctx context.Context, method, path string, + bodyParams interface{}, dest interface{}) (err error) { + var bs []byte + if bodyParams != nil { + bs, err = json.Marshal(bodyParams) + if err != nil { + return fmt.Errorf("encode bodyParams: %w", err) + } + } + req, err := http.NewRequestWithContext( + ctx, + method, + c.requestURL(path), + strings.NewReader(string(bs)), + ) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenHeader, c.Token)) + res, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + if res == nil { + return fmt.Errorf("response: %w", ResponseErr("nil value")) + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("response: %w", WrongStatusErr(res.StatusCode)) + } + if dest == nil { + return nil + } + err = json.NewDecoder(res.Body).Decode(dest) + if err != nil { + return fmt.Errorf("decode body: %w", err) + } + return nil +} + +func extractAllZones(fqdn string) []string { + zones := make([]string, 0) + parts := strings.Split(strings.TrimRight(fqdn, "."), ".") + if len(parts) < 3 { + return zones + } + for i := 1; i < len(parts)-1; i++ { + zones = append(zones, strings.Join(parts[i:], ".")) + } + return zones +} diff --git a/providers/dns/gcore/internal/client_dto.go b/providers/dns/gcore/internal/client_dto.go new file mode 100644 index 0000000000..1272785e5a --- /dev/null +++ b/providers/dns/gcore/internal/client_dto.go @@ -0,0 +1,14 @@ +package internal + +type zoneResponse struct { + Name string `json:"name"` +} + +type zoneRecord struct { + TTL int `json:"ttl"` + ResourceRecords []resourceRecord `json:"resource_records"` +} + +type resourceRecord struct { + Content []string `json:"content"` +} diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go new file mode 100644 index 0000000000..92afb27d1d --- /dev/null +++ b/providers/dns/gcore/internal/client_test.go @@ -0,0 +1,337 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" +) + +const ( + testToken = "test" +) + +func clientForTest() (*http.ServeMux, *Client, func()) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + client := NewClient(testToken, func(client *Client) { + client.BaseURL = server.URL + }) + + return mux, client, server.Close +} + +func Test_extractAllZones(t *testing.T) { + type args struct { + fqdn string + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "success", + args: args{ + fqdn: "_acme-challenge.my.test.domain.com.", + }, + want: []string{"my.test.domain.com", "test.domain.com", "domain.com"}, + }, + { + name: "empty", + args: args{ + fqdn: "_acme-challenge.com.", + }, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractAllZones(tt.args.fqdn); !reflect.DeepEqual(got, tt.want) { + t.Errorf("extractAllZones() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewClient(t *testing.T) { + type args struct { + token string + opts []ClientOpt + } + tests := []struct { + name string + args args + want Client + }{ + { + name: "without opts", + args: args{ + token: "1", + opts: nil, + }, + want: Client{ + HTTPClient: &http.Client{}, + BaseURL: defaultBaseURL, + Token: "1", + }, + }, + { + name: "with opts", + args: args{ + token: "1", + opts: []ClientOpt{ + func(client *Client) { + client.BaseURL = "2" + }, + func(client *Client) { + client.HTTPClient.Timeout = time.Second + }, + }, + }, + want: Client{ + HTTPClient: &http.Client{ + Timeout: time.Second, + }, + BaseURL: "2", + Token: "1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewClient(tt.args.token, tt.args.opts...); !reflect.DeepEqual(*got, tt.want) { + t.Errorf("NewClient() = %+v, want %+v", *got, tt.want) + } + }) + } +} + +func validRequest(w http.ResponseWriter, r *http.Request, waitedMethod string, body interface{}) (ok bool) { + if r.Header.Get("Authorization") != fmt.Sprintf("%s %s", tokenHeader, testToken) { + http.Error(w, "wrong token", http.StatusForbidden) + return false + } + if r.Method != waitedMethod { + http.Error(w, "wrong method", http.StatusForbidden) + return false + } + if body == nil { + return true + } + defer func() { _ = r.Body.Close() }() + err := json.NewDecoder(r.Body).Decode(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return false + } + return true +} + +func sendResponse(w http.ResponseWriter, resp interface{}) { + err := json.NewEncoder(w).Encode(resp) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func enrichMuxWithFindZone(mux *http.ServeMux, path, result string) { + mux.HandleFunc( + path, + func(w http.ResponseWriter, r *http.Request) { + if !validRequest(w, r, http.MethodGet, nil) { + return + } + sendResponse(w, zoneResponse{Name: result}) + }) +} + +func enrichMuxWithAddZoneRecord(mux *http.ServeMux, path string) { + mux.HandleFunc( + path, + func(w http.ResponseWriter, r *http.Request) { + body := zoneRecord{} + if !validRequest(w, r, http.MethodPost, &body) { + return + } + if body.TTL != 10 { + http.Error(w, "wrong ttl", http.StatusInternalServerError) + return + } + if !reflect.DeepEqual(body.ResourceRecords, []resourceRecord{{Content: []string{"acme"}}}) { + http.Error(w, "wrong resource records", http.StatusInternalServerError) + return + } + }) +} + +func TestClient_AddTXTRecord(t *testing.T) { + type args struct { + ctx context.Context + fqdn string + value string + ttl int + } + tests := []struct { + name string + clientGetter func() (*Client, func()) + args args + wantErr bool + }{ + { + name: "success", + clientGetter: func() (*Client, func()) { + mux, cl, cancel := clientForTest() + enrichMuxWithFindZone(mux, "/v2/zones/test.domain.com", "test.domain.com") + enrichMuxWithAddZoneRecord(mux, + "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType) + return cl, cancel + }, + args: args{ + ctx: context.Background(), + fqdn: "_acme-challenge.my.test.domain.com.", + value: "acme", + ttl: 10, + }, + wantErr: false, + }, + { + name: "no zone", + clientGetter: func() (*Client, func()) { + mux, cl, cancel := clientForTest() + enrichMuxWithFindZone(mux, "/v2/zones/not.found.com", "not.found.com") + enrichMuxWithAddZoneRecord(mux, + "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType) + return cl, cancel + }, + args: args{ + ctx: context.Background(), + fqdn: "_acme-challenge.my.test.domain.com.", + value: "acme", + ttl: 10, + }, + wantErr: true, + }, + { + name: "no add", + clientGetter: func() (*Client, func()) { + mux, cl, cancel := clientForTest() + enrichMuxWithFindZone(mux, "/v2/zones/domain.com", "domain.com") + enrichMuxWithAddZoneRecord(mux, + "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType) + return cl, cancel + }, + args: args{ + ctx: context.Background(), + fqdn: "_acme-challenge.my.test.domain.com.", + value: "acme", + ttl: 10, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cl, cancel := tt.clientGetter() + defer cancel() + if err := cl.AddTXTRecord(tt.args.ctx, tt.args.fqdn, tt.args.value, tt.args.ttl); (err != nil) != tt.wantErr { + t.Errorf("AddTXTRecord() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestClient_requestUrl(t *testing.T) { + type args struct { + path string + } + tests := []struct { + name string + client *Client + args args + want string + }{ + { + name: "no trim", + client: &Client{BaseURL: defaultBaseURL}, + args: args{ + path: "path", + }, + want: defaultBaseURL + "/path", + }, + { + name: "base url trim", + client: &Client{BaseURL: "http/"}, + args: args{ + path: "path", + }, + want: "http/path", + }, + { + name: "booth trim", + client: &Client{BaseURL: "http/"}, + args: args{ + path: "/path", + }, + want: "http/path", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := tt.client + if got := c.requestURL(tt.args.path); got != tt.want { + t.Errorf("requestUrl() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestClient_RemoveTXTRecord(t *testing.T) { + type args struct { + ctx context.Context + fqdn string + txt string + } + tests := []struct { + name string + clientGetter func() (*Client, func()) + args args + wantErr bool + }{ + { + name: "success", + clientGetter: func() (*Client, func()) { + mux, cl, cancel := clientForTest() + enrichMuxWithFindZone(mux, "/v2/zones/test.domain.com", "test.domain.com") + mux.HandleFunc( + "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType, + func(w http.ResponseWriter, r *http.Request) { + if !validRequest(w, r, http.MethodDelete, nil) { + return + } + }) + return cl, cancel + }, + args: args{ + ctx: context.Background(), + fqdn: "_acme-challenge.my.test.domain.com.", + txt: "acme", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cl, cancel := tt.clientGetter() + defer cancel() + if err := cl.RemoveTXTRecord(tt.args.ctx, tt.args.fqdn, tt.args.txt); (err != nil) != tt.wantErr { + t.Errorf("RemoveTXTRecord() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From eddfe3e1764b91af6f979888f3cb41699b709296 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 22 Jul 2021 02:42:08 +0200 Subject: [PATCH 2/4] review: client. --- providers/dns/gcore/internal/client.go | 240 ++++------ providers/dns/gcore/internal/client_dto.go | 14 - providers/dns/gcore/internal/client_test.go | 477 ++++++++------------ providers/dns/gcore/internal/types.go | 25 + 4 files changed, 322 insertions(+), 434 deletions(-) delete mode 100644 providers/dns/gcore/internal/client_dto.go create mode 100644 providers/dns/gcore/internal/types.go diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/gcore/internal/client.go index 35f9c0d585..99f25ac9f3 100644 --- a/providers/dns/gcore/internal/client.go +++ b/providers/dns/gcore/internal/client.go @@ -5,202 +5,160 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "net/http" + "net/url" + "path" "strings" + "time" ) const ( defaultBaseURL = "https://dnsapi.gcorelabs.com" tokenHeader = "APIKey" - recordType = "TXT" + txtRecordType = "TXT" ) -type ( - // ResponseErr representation. - ResponseErr string - // WrongStatusErr representation. - WrongStatusErr int - // ClientOpt for constructor of Client. - ClientOpt func(*Client) - // Client for DNS API. - Client struct { - HTTPClient *http.Client - BaseURL string - Token string - } -) - -// Error implementation of error contract. -func (r ResponseErr) Error() string { - return string(r) -} - -// Error implementation of error contract. -func (r WrongStatusErr) Error() string { - return fmt.Sprintf("wrong status = %d", int(r)) -} - -// Status info from response. -func (r WrongStatusErr) Status() int { - return int(r) +// Client for DNS API. +type Client struct { + HTTPClient *http.Client + baseURL *url.URL + token string } // NewClient constructor of Client. -func NewClient(token string, opts ...ClientOpt) *Client { - cl := &Client{Token: token, BaseURL: defaultBaseURL, HTTPClient: &http.Client{}} - for _, op := range opts { - op(cl) +func NewClient(token string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, } - return cl } -// AddTXTRecord to DNS. -func (c *Client) AddTXTRecord(ctx context.Context, fqdn, value string, ttl int) error { - zone, err := c.findZone(ctx, fqdn) - if err != nil { - return fmt.Errorf("find zone: %w", err) - } - rrset := strings.TrimRight(fqdn, ".") - method := http.MethodPost - resourceRecords := []resourceRecord{{Content: []string{value}}} - txt, err := c.zoneTxtRecords(ctx, zone, rrset) - if err == nil && len(txt.ResourceRecords) > 0 { - method = http.MethodPut - resourceRecords = append(resourceRecords, txt.ResourceRecords...) - } - err = c.request( - ctx, - method, - fmt.Sprintf("v2/zones/%s/%s/%s", zone, rrset, recordType), - zoneRecord{ - TTL: ttl, - ResourceRecords: resourceRecords, - }, - nil, - ) +// GetZone gets zone information. +// https://dnsapi.gcorelabs.com/docs#operation/Zone +func (c *Client) GetZone(ctx context.Context, name string) (Zone, error) { + zone := Zone{} + uri := path.Join("/v2/zones", name) + + err := c.do(ctx, http.MethodGet, uri, nil, &zone) if err != nil { - return fmt.Errorf("add record request: %w", err) + return Zone{}, fmt.Errorf("get zone %s: %w", name, err) } - return nil + + return zone, nil } -// RemoveTXTRecord from DNS. -func (c *Client) RemoveTXTRecord(ctx context.Context, fqdn, _ string) error { - zone, err := c.findZone(ctx, fqdn) +// GetRRSet gets RRSet item. +// https://dnsapi.gcorelabs.com/docs#operation/RRSet +func (c *Client) GetRRSet(ctx context.Context, zone, name string) (RRSet, error) { + var result RRSet + uri := path.Join("/v2/zones", zone, name, txtRecordType) + + err := c.do(ctx, http.MethodGet, uri, nil, &result) if err != nil { - return fmt.Errorf("find zone: %w", err) + return RRSet{}, fmt.Errorf("get txt records %s -> %s: %w", zone, name, err) } - err = c.request( - ctx, - http.MethodDelete, - fmt.Sprintf("v2/zones/%s/%s/%s", zone, fqdn, recordType), - nil, - nil, - ) + + return result, nil +} + +// DeleteRRSet removes RRSet record. +// https://dnsapi.gcorelabs.com/docs#operation/DeleteRRSet +func (c *Client) DeleteRRSet(ctx context.Context, zone, name string) error { + uri := path.Join("/v2/zones", zone, name, txtRecordType) + + err := c.do(ctx, http.MethodDelete, uri, nil, nil) if err != nil { // Support DELETE idempotence https://developer.mozilla.org/en-US/docs/Glossary/Idempotent - if statusErr := new(WrongStatusErr); errors.As(err, statusErr) && - statusErr.Status() == http.StatusNotFound { + statusErr := new(APIError) + if errors.As(err, statusErr) && statusErr.StatusCode == http.StatusNotFound { return nil } + return fmt.Errorf("delete record request: %w", err) } + return nil } -func (c *Client) findZone(ctx context.Context, fqdn string) (dnsZone string, err error) { - possibleZones := extractAllZones(fqdn) - for _, zone := range possibleZones { - dnsZone, err = c.zone(ctx, zone) - if err == nil { - return dnsZone, nil - } - } - return "", fmt.Errorf("zone not found: %w", err) -} +// AddRRSet adds TXT record (create or update). +func (c *Client) AddRRSet(ctx context.Context, zone, recordName, value string, ttl int) error { + record := RRSet{TTL: ttl, Records: []Records{{Content: []string{value}}}} -func (c *Client) zoneTxtRecords(ctx context.Context, zone, rrset string) (result zoneRecord, err error) { - err = c.request( - ctx, - http.MethodGet, - fmt.Sprintf("v2/zones/%s/%s/%s", zone, rrset, recordType), - nil, - &result, - ) - if err != nil { - return zoneRecord{}, fmt.Errorf("get zone txt records %s -> %s: %w", zone, rrset, err) + txt, err := c.GetRRSet(ctx, zone, recordName) + if err == nil && len(txt.Records) > 0 { + record.Records = append(record.Records, txt.Records...) + return c.updateRRSet(ctx, zone, recordName, record) } - return result, nil + + return c.createRRSet(ctx, zone, recordName, record) } -func (c *Client) zone(ctx context.Context, zone string) (string, error) { - response := zoneResponse{} - err := c.request( - ctx, - http.MethodGet, - fmt.Sprintf("v2/zones/%s", zone), - nil, - &response, - ) - if err != nil { - return "", fmt.Errorf("get zone %s: %w", zone, err) - } - return response.Name, nil +// https://dnsapi.gcorelabs.com/docs#operation/CreateRRSet +func (c *Client) createRRSet(ctx context.Context, zone, name string, record RRSet) error { + uri := path.Join("/v2/zones", zone, name, txtRecordType) + + return c.do(ctx, http.MethodPost, uri, record, nil) } -func (c *Client) requestURL(path string) string { - return strings.TrimRight(c.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") +// https://dnsapi.gcorelabs.com/docs#operation/UpdateRRSet +func (c *Client) updateRRSet(ctx context.Context, zone, name string, record RRSet) error { + uri := path.Join("/v2/zones", zone, name, txtRecordType) + + return c.do(ctx, http.MethodPut, uri, record, nil) } -func (c *Client) request(ctx context.Context, method, path string, - bodyParams interface{}, dest interface{}) (err error) { +func (c *Client) do(ctx context.Context, method, uri string, bodyParams interface{}, dest interface{}) error { var bs []byte if bodyParams != nil { + var err error bs, err = json.Marshal(bodyParams) if err != nil { return fmt.Errorf("encode bodyParams: %w", err) } } - req, err := http.NewRequestWithContext( - ctx, - method, - c.requestURL(path), - strings.NewReader(string(bs)), - ) + + endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, uri)) + if err != nil { + return fmt.Errorf("failed to parse endpoint: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), strings.NewReader(string(bs))) if err != nil { return fmt.Errorf("new request: %w", err) } + req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenHeader, c.Token)) - res, err := c.HTTPClient.Do(req) + req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenHeader, c.token)) + + resp, err := c.HTTPClient.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } - if res == nil { - return fmt.Errorf("response: %w", ResponseErr("nil value")) - } - defer func() { _ = res.Body.Close() }() - if res.StatusCode >= http.StatusMultipleChoices { - return fmt.Errorf("response: %w", WrongStatusErr(res.StatusCode)) + + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode/100 != 2 { + all, _ := ioutil.ReadAll(resp.Body) + + e := APIError{ + StatusCode: resp.StatusCode, + } + + err := json.Unmarshal(all, &e) + if err != nil { + e.Message = string(all) + } + + return e } + if dest == nil { return nil } - err = json.NewDecoder(res.Body).Decode(dest) - if err != nil { - return fmt.Errorf("decode body: %w", err) - } - return nil -} -func extractAllZones(fqdn string) []string { - zones := make([]string, 0) - parts := strings.Split(strings.TrimRight(fqdn, "."), ".") - if len(parts) < 3 { - return zones - } - for i := 1; i < len(parts)-1; i++ { - zones = append(zones, strings.Join(parts[i:], ".")) - } - return zones + return json.NewDecoder(resp.Body).Decode(dest) } diff --git a/providers/dns/gcore/internal/client_dto.go b/providers/dns/gcore/internal/client_dto.go deleted file mode 100644 index 1272785e5a..0000000000 --- a/providers/dns/gcore/internal/client_dto.go +++ /dev/null @@ -1,14 +0,0 @@ -package internal - -type zoneResponse struct { - Name string `json:"name"` -} - -type zoneRecord struct { - TTL int `json:"ttl"` - ResourceRecords []resourceRecord `json:"resource_records"` -} - -type resourceRecord struct { - Content []string `json:"content"` -} diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go index 92afb27d1d..24a253c9b1 100644 --- a/providers/dns/gcore/internal/client_test.go +++ b/providers/dns/gcore/internal/client_test.go @@ -6,332 +6,251 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "reflect" "testing" - "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( - testToken = "test" + testToken = "test" + testRecordContent = "acme" + testRecordContent2 = "foo" + testTTL = 10 ) -func clientForTest() (*http.ServeMux, *Client, func()) { +func setupTest(t *testing.T) (*http.ServeMux, *Client) { + t.Helper() + mux := http.NewServeMux() + server := httptest.NewServer(mux) - client := NewClient(testToken, func(client *Client) { - client.BaseURL = server.URL + t.Cleanup(server.Close) + + client := NewClient(testToken) + client.baseURL, _ = url.Parse(server.URL) + + return mux, client +} + +func TestClient_GetZone(t *testing.T) { + mux, client := setupTest(t) + + expected := Zone{Name: "example.com"} + + mux.Handle("/v2/zones/example.com", validationHandler{ + method: http.MethodGet, + next: handleJSONResponse(expected), }) - return mux, client, server.Close + zone, err := client.GetZone(context.Background(), "example.com") + require.NoError(t, err) + + assert.Equal(t, expected, zone) } -func Test_extractAllZones(t *testing.T) { - type args struct { - fqdn string - } - tests := []struct { - name string - args args - want []string - }{ - { - name: "success", - args: args{ - fqdn: "_acme-challenge.my.test.domain.com.", - }, - want: []string{"my.test.domain.com", "test.domain.com", "domain.com"}, - }, - { - name: "empty", - args: args{ - fqdn: "_acme-challenge.com.", - }, - want: []string{}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := extractAllZones(tt.args.fqdn); !reflect.DeepEqual(got, tt.want) { - t.Errorf("extractAllZones() = %v, want %v", got, tt.want) - } - }) - } +func TestClient_GetZone_error(t *testing.T) { + mux, client := setupTest(t) + + mux.Handle("/v2/zones/example.com", validationHandler{ + method: http.MethodGet, + next: handleAPIError(), + }) + + _, err := client.GetZone(context.Background(), "example.com") + require.Error(t, err) } -func TestNewClient(t *testing.T) { - type args struct { - token string - opts []ClientOpt - } - tests := []struct { - name string - args args - want Client - }{ - { - name: "without opts", - args: args{ - token: "1", - opts: nil, - }, - want: Client{ - HTTPClient: &http.Client{}, - BaseURL: defaultBaseURL, - Token: "1", - }, - }, - { - name: "with opts", - args: args{ - token: "1", - opts: []ClientOpt{ - func(client *Client) { - client.BaseURL = "2" - }, - func(client *Client) { - client.HTTPClient.Timeout = time.Second - }, - }, - }, - want: Client{ - HTTPClient: &http.Client{ - Timeout: time.Second, - }, - BaseURL: "2", - Token: "1", - }, +func TestClient_GetRRSet(t *testing.T) { + mux, client := setupTest(t) + + expected := RRSet{ + TTL: testTTL, + Records: []Records{ + {Content: []string{testRecordContent}}, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := NewClient(tt.args.token, tt.args.opts...); !reflect.DeepEqual(*got, tt.want) { - t.Errorf("NewClient() = %+v, want %+v", *got, tt.want) - } - }) - } -} -func validRequest(w http.ResponseWriter, r *http.Request, waitedMethod string, body interface{}) (ok bool) { - if r.Header.Get("Authorization") != fmt.Sprintf("%s %s", tokenHeader, testToken) { - http.Error(w, "wrong token", http.StatusForbidden) - return false - } - if r.Method != waitedMethod { - http.Error(w, "wrong method", http.StatusForbidden) - return false - } - if body == nil { - return true - } - defer func() { _ = r.Body.Close() }() - err := json.NewDecoder(r.Body).Decode(body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return false - } - return true + mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{ + method: http.MethodGet, + next: handleJSONResponse(expected), + }) + + rrSet, err := client.GetRRSet(context.Background(), "example.com", "foo.example.com") + require.NoError(t, err) + + assert.Equal(t, expected, rrSet) } -func sendResponse(w http.ResponseWriter, resp interface{}) { - err := json.NewEncoder(w).Encode(resp) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } +func TestClient_GetRRSet_error(t *testing.T) { + mux, client := setupTest(t) + + mux.Handle("/v2/zones/example.com/foo.example.com/TXT", validationHandler{ + method: http.MethodGet, + next: handleAPIError(), + }) + + _, err := client.GetRRSet(context.Background(), "example.com", "foo.example.com") + require.Error(t, err) } -func enrichMuxWithFindZone(mux *http.ServeMux, path, result string) { - mux.HandleFunc( - path, - func(w http.ResponseWriter, r *http.Request) { - if !validRequest(w, r, http.MethodGet, nil) { - return - } - sendResponse(w, zoneResponse{Name: result}) - }) +func TestClient_DeleteRRSet(t *testing.T) { + mux, client := setupTest(t) + + mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, + validationHandler{method: http.MethodDelete}) + + err := client.DeleteRRSet(context.Background(), "test.example.com", "my.test.example.com.") + require.NoError(t, err) } -func enrichMuxWithAddZoneRecord(mux *http.ServeMux, path string) { - mux.HandleFunc( - path, - func(w http.ResponseWriter, r *http.Request) { - body := zoneRecord{} - if !validRequest(w, r, http.MethodPost, &body) { - return - } - if body.TTL != 10 { - http.Error(w, "wrong ttl", http.StatusInternalServerError) - return - } - if !reflect.DeepEqual(body.ResourceRecords, []resourceRecord{{Content: []string{"acme"}}}) { - http.Error(w, "wrong resource records", http.StatusInternalServerError) - return - } - }) +func TestClient_DeleteRRSet_error(t *testing.T) { + mux, client := setupTest(t) + + mux.Handle("/v2/zones/test.example.com/my.test.example.com/"+txtRecordType, validationHandler{ + method: http.MethodDelete, + next: handleAPIError(), + }) + + err := client.DeleteRRSet(context.Background(), "test.example.com", "my.test.example.com.") + require.NoError(t, err) } -func TestClient_AddTXTRecord(t *testing.T) { - type args struct { - ctx context.Context - fqdn string - value string - ttl int - } - tests := []struct { - name string - clientGetter func() (*Client, func()) - args args - wantErr bool +func TestClient_AddRRSet(t *testing.T) { + testCases := []struct { + desc string + zone string + recordName string + value string + handledDomain string + handlers map[string]http.Handler + wantErr bool }{ { - name: "success", - clientGetter: func() (*Client, func()) { - mux, cl, cancel := clientForTest() - enrichMuxWithFindZone(mux, "/v2/zones/test.domain.com", "test.domain.com") - enrichMuxWithAddZoneRecord(mux, - "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType) - return cl, cancel - }, - args: args{ - ctx: context.Background(), - fqdn: "_acme-challenge.my.test.domain.com.", - value: "acme", - ttl: 10, + desc: "success add", + zone: "test.example.com", + recordName: "my.test.example.com", + value: testRecordContent, + handlers: map[string]http.Handler{ + // createRRSet + "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: validationHandler{ + method: http.MethodPost, + next: handleAddRRSet([]Records{{Content: []string{testRecordContent}}}), + }, }, - wantErr: false, }, { - name: "no zone", - clientGetter: func() (*Client, func()) { - mux, cl, cancel := clientForTest() - enrichMuxWithFindZone(mux, "/v2/zones/not.found.com", "not.found.com") - enrichMuxWithAddZoneRecord(mux, - "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType) - return cl, cancel - }, - args: args{ - ctx: context.Background(), - fqdn: "_acme-challenge.my.test.domain.com.", - value: "acme", - ttl: 10, + desc: "success update", + zone: "test.example.com", + recordName: "my.test.example.com", + value: testRecordContent, + handlers: map[string]http.Handler{ + "/v2/zones/test.example.com/my.test.example.com/" + txtRecordType: http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + switch req.Method { + case http.MethodGet: // GetRRSet + data := RRSet{ + TTL: testTTL, + Records: []Records{{Content: []string{testRecordContent2}}}, + } + handleJSONResponse(data).ServeHTTP(rw, req) + case http.MethodPut: // updateRRSet + expected := []Records{ + {Content: []string{testRecordContent}}, + {Content: []string{testRecordContent2}}, + } + handleAddRRSet(expected).ServeHTTP(rw, req) + default: + http.Error(rw, "wrong method", http.StatusMethodNotAllowed) + } + }), }, - wantErr: true, }, { - name: "no add", - clientGetter: func() (*Client, func()) { - mux, cl, cancel := clientForTest() - enrichMuxWithFindZone(mux, "/v2/zones/domain.com", "domain.com") - enrichMuxWithAddZoneRecord(mux, - "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType) - return cl, cancel - }, - args: args{ - ctx: context.Background(), - fqdn: "_acme-challenge.my.test.domain.com.", - value: "acme", - ttl: 10, - }, - wantErr: true, + desc: "not in the zone", + zone: "test.example.com", + recordName: "notfound.example.com", + value: testRecordContent, + wantErr: true, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cl, cancel := tt.clientGetter() - defer cancel() - if err := cl.AddTXTRecord(tt.args.ctx, tt.args.fqdn, tt.args.value, tt.args.ttl); (err != nil) != tt.wantErr { - t.Errorf("AddTXTRecord() error = %v, wantErr %v", err, tt.wantErr) + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + mux, cl := setupTest(t) + + for pattern, handler := range test.handlers { + mux.Handle(pattern, handler) + } + + err := cl.AddRRSet(context.Background(), test.zone, test.recordName, test.value, testTTL) + if test.wantErr { + require.Error(t, err) + return } + + require.NoError(t, err) }) } } -func TestClient_requestUrl(t *testing.T) { - type args struct { - path string +type validationHandler struct { + method string + next http.Handler +} + +func (v validationHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if req.Header.Get("Authorization") != fmt.Sprintf("%s %s", tokenHeader, testToken) { + rw.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(rw).Encode(APIError{Message: "token up for parsing was not passed through the context"}) + return } - tests := []struct { - name string - client *Client - args args - want string - }{ - { - name: "no trim", - client: &Client{BaseURL: defaultBaseURL}, - args: args{ - path: "path", - }, - want: defaultBaseURL + "/path", - }, - { - name: "base url trim", - client: &Client{BaseURL: "http/"}, - args: args{ - path: "path", - }, - want: "http/path", - }, - { - name: "booth trim", - client: &Client{BaseURL: "http/"}, - args: args{ - path: "/path", - }, - want: "http/path", - }, + + if req.Method != v.method { + http.Error(rw, "wrong method", http.StatusMethodNotAllowed) + return } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := tt.client - if got := c.requestURL(tt.args.path); got != tt.want { - t.Errorf("requestUrl() = %v, want %v", got, tt.want) - } - }) + + if v.next != nil { + v.next.ServeHTTP(rw, req) } } -func TestClient_RemoveTXTRecord(t *testing.T) { - type args struct { - ctx context.Context - fqdn string - txt string +func handleAPIError() http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(rw).Encode(APIError{Message: "oops"}) } - tests := []struct { - name string - clientGetter func() (*Client, func()) - args args - wantErr bool - }{ - { - name: "success", - clientGetter: func() (*Client, func()) { - mux, cl, cancel := clientForTest() - enrichMuxWithFindZone(mux, "/v2/zones/test.domain.com", "test.domain.com") - mux.HandleFunc( - "/v2/zones/test.domain.com/_acme-challenge.my.test.domain.com/"+recordType, - func(w http.ResponseWriter, r *http.Request) { - if !validRequest(w, r, http.MethodDelete, nil) { - return - } - }) - return cl, cancel - }, - args: args{ - ctx: context.Background(), - fqdn: "_acme-challenge.my.test.domain.com.", - txt: "acme", - }, - wantErr: false, - }, +} + +func handleJSONResponse(data interface{}) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + err := json.NewEncoder(rw).Encode(data) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cl, cancel := tt.clientGetter() - defer cancel() - if err := cl.RemoveTXTRecord(tt.args.ctx, tt.args.fqdn, tt.args.txt); (err != nil) != tt.wantErr { - t.Errorf("RemoveTXTRecord() error = %v, wantErr %v", err, tt.wantErr) - } - }) +} + +func handleAddRRSet(expected []Records) http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + body := RRSet{} + + err := json.NewDecoder(req.Body).Decode(&body) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + if body.TTL != testTTL { + http.Error(rw, "wrong ttl", http.StatusInternalServerError) + return + } + + if !reflect.DeepEqual(body.Records, expected) { + http.Error(rw, "wrong resource records", http.StatusInternalServerError) + } } } diff --git a/providers/dns/gcore/internal/types.go b/providers/dns/gcore/internal/types.go new file mode 100644 index 0000000000..4245f5ba89 --- /dev/null +++ b/providers/dns/gcore/internal/types.go @@ -0,0 +1,25 @@ +package internal + +import "fmt" + +type Zone struct { + Name string `json:"name"` +} + +type RRSet struct { + TTL int `json:"ttl"` + Records []Records `json:"resource_records"` +} + +type Records struct { + Content []string `json:"content"` +} + +type APIError struct { + StatusCode int `json:"-"` + Message string `json:"error,omitempty"` +} + +func (a APIError) Error() string { + return fmt.Sprintf("%d: %s", a.StatusCode, a.Message) +} From e8b2061cd2f7594fe6660a4cd95c0b940c65d285 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 22 Jul 2021 03:12:39 +0200 Subject: [PATCH 3/4] review: provider --- providers/dns/dns_providers.go | 8 +- providers/dns/gcore/gcore.go | 185 ++++++++++++++++--------- providers/dns/gcore/gcore.toml | 19 ++- providers/dns/gcore/gcore_test.go | 221 +++++++++++------------------- 4 files changed, 212 insertions(+), 221 deletions(-) diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index e73bc85fc6..dc79aa7d7f 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -174,10 +174,12 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return gandi.NewDNSProvider() case "gandiv5": return gandiv5.NewDNSProvider() - case "glesys": - return glesys.NewDNSProvider() case "gcloud": return gcloud.NewDNSProvider() + case "gcore": + return gcore.NewDNSProvider() + case "glesys": + return glesys.NewDNSProvider() case "godaddy": return godaddy.NewDNSProvider() case "hetzner": @@ -292,8 +294,6 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return zoneee.NewDNSProvider() case "zonomi": return zonomi.NewDNSProvider() - case gcore.ProviderCode: - return gcore.NewDNSProvider() default: return nil, fmt.Errorf("unrecognized DNS provider: %s", name) } diff --git a/providers/dns/gcore/gcore.go b/providers/dns/gcore/gcore.go index 092b621d59..9692358bd0 100644 --- a/providers/dns/gcore/gcore.go +++ b/providers/dns/gcore/gcore.go @@ -2,7 +2,10 @@ package gcore import ( "context" + "errors" "fmt" + "net/http" + "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" @@ -11,107 +14,153 @@ import ( ) const ( - // ProviderCode a value for cli dns flag. - ProviderCode = "gcore" - - envNamespace = "GCORE_" - envAPIUrl = envNamespace + "_API_URL" - envPermanentToken = envNamespace + "PERMANENT_API_TOKEN" - envTTL = envNamespace + "TTL" - envPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" - envPollingInterval = envNamespace + "POLLING_INTERVAL" - envHTTPTimeout = envNamespace + "HTTP_TIMEOUT" - defaultPropagationTimeout = 360 * time.Second defaultPollingInterval = 20 * time.Second ) -type ( - // TXTRecordManager contract for API client. - TXTRecordManager interface { - AddTXTRecord(ctx context.Context, fqdn, value string, ttl int) error - RemoveTXTRecord(ctx context.Context, fqdn, value string) error - } - // Config for DNSProvider. - Config struct { - PropagationTimeout time.Duration - PollingInterval time.Duration - TTL int - HTTPTimeout time.Duration - } - // DNSProviderOpt for constructor of DNSProvider. - DNSProviderOpt func(*DNSProvider) - // DNSProvider an implementation of challenge.Provider contract. - DNSProvider struct { - Config - Client TXTRecordManager - } +// Environment variables names. +const ( + envNamespace = "GCORE_" + + EnvPermanentAPIToken = envNamespace + "PERMANENT_API_TOKEN" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) +// Config for DNSProvider. +type Config struct { + APIToken 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(envTTL, dns01.DefaultTTL), - PropagationTimeout: env.GetOrDefaultSecond(envPropagationTimeout, defaultPropagationTimeout), - PollingInterval: env.GetOrDefaultSecond(envPollingInterval, defaultPollingInterval), - HTTPTimeout: env.GetOrDefaultSecond(envHTTPTimeout, defaultPropagationTimeout), +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), + HTTPClient: &http.Client{ + Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second), + }, } } +// DNSProvider an implementation of challenge.Provider contract. +type DNSProvider struct { + config *Config + client *internal.Client +} + // NewDNSProvider returns an instance of DNSProvider configured for G-Core Labs DNS API. -func NewDNSProvider(opts ...DNSProviderOpt) (*DNSProvider, error) { - values, err := env.Get(envPermanentToken) +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvPermanentAPIToken) if err != nil { - return nil, fmt.Errorf("%s: required env vars: %w", ProviderCode, err) + return nil, fmt.Errorf("gcore: %w", err) } - cfg := NewDefaultConfig() - p := &DNSProvider{ - Config: cfg, - Client: internal.NewClient( - values[envPermanentToken], - func(client *internal.Client) { - client.HTTPClient.Timeout = cfg.HTTPTimeout - }, - func(client *internal.Client) { - url := env.GetOrDefaultString(envAPIUrl, "") - if url != "" { - client.BaseURL = url - } - }, - ), + + config := NewDefaultConfig() + config.APIToken = values[EnvPermanentAPIToken] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for G-Core Labs DNS API. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("gcore: the configuration of the DNS provider is nil") } - for _, op := range opts { - op(p) + + if config.APIToken == "" { + return nil, errors.New("gcore: incomplete credentials provided") } - return p, nil + + client := internal.NewClient(config.APIToken) + + if config.HTTPClient != nil { + client.HTTPClient = config.HTTPClient + } + + return &DNSProvider{ + config: config, + client: client, + }, nil } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { fqdn, value := dns01.GetRecord(domain, keyAuth) - ctx, cancel := context.WithTimeout(context.Background(), d.Config.PropagationTimeout) - defer cancel() - err := d.Client.AddTXTRecord(ctx, fqdn, value, d.Config.TTL) + + ctx := context.Background() + + zone, err := d.guessZone(ctx, fqdn) if err != nil { - return fmt.Errorf("add txt record: %w", err) + return fmt.Errorf("gcore: %w", err) } + + err = d.client.AddRRSet(ctx, zone, dns01.UnFqdn(fqdn), value, d.config.TTL) + if err != nil { + return fmt.Errorf("gcore: add txt record: %w", err) + } + return nil } // CleanUp removes the record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { - fqdn, value := dns01.GetRecord(domain, keyAuth) - ctx, cancel := context.WithTimeout(context.Background(), d.Config.PropagationTimeout) - defer cancel() - err := d.Client.RemoveTXTRecord(ctx, fqdn, value) + fqdn, _ := dns01.GetRecord(domain, keyAuth) + + ctx := context.Background() + + zone, err := d.guessZone(ctx, fqdn) + if err != nil { + return fmt.Errorf("gcore: %w", err) + } + + err = d.client.DeleteRRSet(ctx, zone, dns01.UnFqdn(fqdn)) if err != nil { - return fmt.Errorf("remove txt record: %w", err) + return fmt.Errorf("gcore: remove txt record: %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 (d *DNSProvider) Timeout() (timeout, interval time.Duration) { - return d.Config.PropagationTimeout, d.Config.PollingInterval + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) guessZone(ctx context.Context, fqdn string) (string, error) { + var lastErr error + + for _, zone := range extractAllZones(fqdn) { + dnsZone, err := d.client.GetZone(ctx, zone) + if err == nil { + return dnsZone.Name, nil + } + + lastErr = err + } + + return "", fmt.Errorf("zone %q not found: %w", fqdn, lastErr) +} + +func extractAllZones(fqdn string) []string { + parts := strings.Split(dns01.UnFqdn(fqdn), ".") + if len(parts) < 3 { + return nil + } + + var zones []string + for i := 1; i < len(parts)-1; i++ { + zones = append(zones, strings.Join(parts[i:], ".")) + } + + return zones } diff --git a/providers/dns/gcore/gcore.toml b/providers/dns/gcore/gcore.toml index c1375e9321..217b896811 100644 --- a/providers/dns/gcore/gcore.toml +++ b/providers/dns/gcore/gcore.toml @@ -5,19 +5,18 @@ Code = "gcore" Since = "v4.5.0" Example = ''' -GCORE_PERMANENT_API_TOKEN= \ +GCORE_PERMANENT_API_TOKEN=xxxxx \ lego --email myemail@example.com --dns gcore --domains my.example.org run ''' [Configuration] -[Configuration.Credentials] -GCORE_PERMANENT_API_TOKEN = "Permanent API tokene (https://gcorelabs.com/blog/permanent-api-token-explained/)" -[Configuration.Additional] -GCORE_API_URL = "G-Cole Labs DNS API URL (default: http://dnsapi.gcorelabs.com/)" -GCORE_POLLING_INTERVAL = "Time between DNS propagation check" -GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" -GCORE_HTTP_TIMEOUT = "API request timeout" -GCORE_TTL = "The TTL of the TXT record used for the DNS challenge" + [Configuration.Credentials] + GCORE_PERMANENT_API_TOKEN = "Permanent API tokene (https://gcorelabs.com/blog/permanent-api-token-explained/)" + [Configuration.Additional] + GCORE_POLLING_INTERVAL = "Time between DNS propagation check" + GCORE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + GCORE_TTL = "The TTL of the TXT record used for the DNS challenge" + GCORE_HTTP_TIMEOUT = "API request timeout" [Links] -API = "https://dnsapi.gcorelabs.com/docs#tag/zonesV2" + API = "https://dnsapi.gcorelabs.com/docs#tag/zonesV2" diff --git a/providers/dns/gcore/gcore_test.go b/providers/dns/gcore/gcore_test.go index 8e991c3021..ba905c2ce2 100644 --- a/providers/dns/gcore/gcore_test.go +++ b/providers/dns/gcore/gcore_test.go @@ -1,172 +1,87 @@ package gcore import ( - "context" - "fmt" - "os" - "reflect" - "strings" "testing" - "time" - "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var envTest = tester.NewEnvTest(envPermanentToken).WithDomain(envNamespace + "DOMAIN") +var envTest = tester.NewEnvTest(EnvPermanentAPIToken).WithDomain(envNamespace + "DOMAIN") -type mockClient struct{} - -func (m mockClient) AddTXTRecord(ctx context.Context, fqdn, value string, ttl int) error { - if strings.Contains(fqdn, "err") { - return fmt.Errorf("err") - } - return nil -} - -func (m mockClient) RemoveTXTRecord(ctx context.Context, fqdn, value string) error { - if strings.Contains(fqdn, "err") { - return fmt.Errorf("err") - } - return nil -} - -func TestNewDefaultConfig(t *testing.T) { - tests := []struct { - name string - exec func() - want Config +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string }{ { - name: "default", - exec: func() {}, - want: Config{ - PropagationTimeout: defaultPropagationTimeout, - PollingInterval: defaultPollingInterval, - TTL: dns01.DefaultTTL, - HTTPTimeout: defaultPropagationTimeout, + desc: "success", + envVars: map[string]string{ + EnvPermanentAPIToken: "A", }, }, { - name: "custom", - exec: func() { - _ = os.Setenv(envTTL, fmt.Sprintf("%d", 10)) - _ = os.Setenv(envHTTPTimeout, fmt.Sprintf("%d", 1)) - _ = os.Setenv(envPollingInterval, fmt.Sprintf("%d", 4)) - _ = os.Setenv(envPropagationTimeout, fmt.Sprintf("%d", 6)) - }, - want: Config{ - PropagationTimeout: 6 * time.Second, - PollingInterval: 4 * time.Second, - TTL: 10, - HTTPTimeout: time.Second, + desc: "missing credentials", + envVars: map[string]string{ + EnvPermanentAPIToken: "", }, + expected: "gcore: some credentials information are missing: GCORE_PERMANENT_API_TOKEN", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.exec() - if got := NewDefaultConfig(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("NewDefaultConfig() = %v, want %v", got, tt.want) - } - }) - } -} -func TestDNSProvider_Present(t *testing.T) { - type args struct { - domain string - token string - keyAuth string - } - tests := []struct { - name string - provider *DNSProvider - args args - wantErr bool - }{ - { - name: "success", - provider: &DNSProvider{ - Config: NewDefaultConfig(), - Client: mockClient{}, - }, - args: args{ - domain: "any", - token: "", - keyAuth: "", - }, - wantErr: false, - }, - { - name: "error", - provider: &DNSProvider{ - Config: NewDefaultConfig(), - Client: mockClient{}, - }, - args: args{ - domain: "err", - token: "", - keyAuth: "", - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := tt.provider - if err := d.Present(tt.args.domain, tt.args.token, tt.args.keyAuth); (err != nil) != tt.wantErr { - t.Errorf("Present() error = %v, wantErr %v", err, tt.wantErr) + 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) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) } }) } } -func TestDNSProvider_CleanUp(t *testing.T) { - type args struct { - domain string - token string - keyAuth string - } - tests := []struct { - name string - provider *DNSProvider - args args - wantErr bool +func TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string }{ { - name: "success", - provider: &DNSProvider{ - Config: NewDefaultConfig(), - Client: mockClient{}, - }, - args: args{ - domain: "any", - token: "", - keyAuth: "", - }, - wantErr: false, + desc: "success", + apiToken: "A", }, { - name: "error", - provider: &DNSProvider{ - Config: NewDefaultConfig(), - Client: mockClient{}, - }, - args: args{ - domain: "err", - token: "", - keyAuth: "", - }, - wantErr: true, + desc: "missing credentials", + expected: "gcore: incomplete credentials provided", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - d := tt.provider - if err := d.CleanUp(tt.args.domain, tt.args.token, tt.args.keyAuth); (err != nil) != tt.wantErr { - t.Errorf("CleanUp() error = %v, wantErr %v", err, tt.wantErr) + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.APIToken = test.apiToken + + p, err := NewDNSProviderConfig(config) + + if test.expected == "" { + require.NoError(t, err) + require.NotNil(t, p) + require.NotNil(t, p.config) + require.NotNil(t, p.client) + } else { + require.EqualError(t, err, test.expected) } }) } @@ -197,3 +112,31 @@ func TestLiveCleanUp(t *testing.T) { err = provider.CleanUp(envTest.GetDomain(), "", "123d==") require.NoError(t, err) } + +func Test_extractAllZones(t *testing.T) { + testCases := []struct { + desc string + fqdn string + expected []string + }{ + { + desc: "success", + fqdn: "_acme-challenge.my.test.domain.com.", + expected: []string{"my.test.domain.com", "test.domain.com", "domain.com"}, + }, + { + desc: "empty", + fqdn: "_acme-challenge.com.", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + got := extractAllZones(test.fqdn) + assert.Equal(t, test.expected, got) + }) + } +} From abe85c8a753c0db3d8b654e07fd3ae1414dc1e31 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Thu, 22 Jul 2021 03:13:12 +0200 Subject: [PATCH 4/4] review: generate --- cmd/zz_gen_cmd_dnshelp.go | 1 - docs/content/dns/zz_gen_gcore.md | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 86bac468e1..3e3c382f91 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -894,7 +894,6 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`Additional Configuration:`) - ew.writeln(` - "GCORE_API_URL": G-Cole Labs DNS API URL (default: http://dnsapi.gcorelabs.com/)`) ew.writeln(` - "GCORE_HTTP_TIMEOUT": API request timeout`) ew.writeln(` - "GCORE_POLLING_INTERVAL": Time between DNS propagation check`) ew.writeln(` - "GCORE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) diff --git a/docs/content/dns/zz_gen_gcore.md b/docs/content/dns/zz_gen_gcore.md index af47d47e5b..40fd9cca10 100644 --- a/docs/content/dns/zz_gen_gcore.md +++ b/docs/content/dns/zz_gen_gcore.md @@ -21,7 +21,7 @@ Configuration for [G-Core Labs](https://gcorelabs.com/dns/). Here is an example bash command using the G-Core Labs provider: ```bash -GCORE_PERMANENT_API_TOKEN= \ +GCORE_PERMANENT_API_TOKEN=xxxxx \ lego --email myemail@example.com --dns gcore --domains my.example.org run ``` @@ -42,7 +42,6 @@ More information [here](/lego/dns/#configuration-and-credentials). | Environment Variable Name | Description | |--------------------------------|-------------| -| `GCORE_API_URL` | G-Cole Labs DNS API URL (default: http://dnsapi.gcorelabs.com/) | | `GCORE_HTTP_TIMEOUT` | API request timeout | | `GCORE_POLLING_INTERVAL` | Time between DNS propagation check | | `GCORE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation |