diff --git a/README.md b/README.md index 67e6d600..c4c5ee80 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 cd426537..3e3c382f 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,26 @@ 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_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 00000000..40fd9cca --- /dev/null +++ b/docs/content/dns/zz_gen_gcore.md @@ -0,0 +1,62 @@ +--- +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=xxxxx \ +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_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 51fd0c4b..dc79aa7d 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" @@ -173,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": diff --git a/providers/dns/gcore/gcore.go b/providers/dns/gcore/gcore.go new file mode 100644 index 00000000..9692358b --- /dev/null +++ b/providers/dns/gcore/gcore.go @@ -0,0 +1,166 @@ +package gcore + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "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 ( + defaultPropagationTimeout = 360 * time.Second + defaultPollingInterval = 20 * time.Second +) + +// 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), + 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() (*DNSProvider, error) { + values, err := env.Get(EnvPermanentAPIToken) + if err != nil { + return nil, fmt.Errorf("gcore: %w", err) + } + + 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") + } + + if config.APIToken == "" { + return nil, errors.New("gcore: incomplete credentials provided") + } + + 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 := context.Background() + + zone, err := d.guessZone(ctx, fqdn) + if err != nil { + 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, _ := 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("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 +} + +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 new file mode 100644 index 00000000..217b8968 --- /dev/null +++ b/providers/dns/gcore/gcore.toml @@ -0,0 +1,22 @@ +Name = "G-Core Labs" +Description = '''''' +URL = "https://gcorelabs.com/dns/" +Code = "gcore" +Since = "v4.5.0" + +Example = ''' +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_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" diff --git a/providers/dns/gcore/gcore_test.go b/providers/dns/gcore/gcore_test.go new file mode 100644 index 00000000..ba905c2c --- /dev/null +++ b/providers/dns/gcore/gcore_test.go @@ -0,0 +1,142 @@ +package gcore + +import ( + "testing" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var envTest = tester.NewEnvTest(EnvPermanentAPIToken).WithDomain(envNamespace + "DOMAIN") + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvPermanentAPIToken: "A", + }, + }, + { + desc: "missing credentials", + envVars: map[string]string{ + EnvPermanentAPIToken: "", + }, + expected: "gcore: some credentials information are missing: GCORE_PERMANENT_API_TOKEN", + }, + } + + 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 TestNewDNSProviderConfig(t *testing.T) { + testCases := []struct { + desc string + apiToken string + expected string + }{ + { + desc: "success", + apiToken: "A", + }, + { + desc: "missing credentials", + expected: "gcore: incomplete credentials provided", + }, + } + + 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) + } + }) + } +} + +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) +} + +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) + }) + } +} diff --git a/providers/dns/gcore/internal/client.go b/providers/dns/gcore/internal/client.go new file mode 100644 index 00000000..99f25ac9 --- /dev/null +++ b/providers/dns/gcore/internal/client.go @@ -0,0 +1,164 @@ +package internal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +const ( + defaultBaseURL = "https://dnsapi.gcorelabs.com" + tokenHeader = "APIKey" + txtRecordType = "TXT" +) + +// Client for DNS API. +type Client struct { + HTTPClient *http.Client + baseURL *url.URL + token string +} + +// NewClient constructor of Client. +func NewClient(token string) *Client { + baseURL, _ := url.Parse(defaultBaseURL) + + return &Client{ + token: token, + baseURL: baseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +// 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 Zone{}, fmt.Errorf("get zone %s: %w", name, err) + } + + return zone, nil +} + +// 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 RRSet{}, fmt.Errorf("get txt records %s -> %s: %w", zone, name, err) + } + + 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 + statusErr := new(APIError) + if errors.As(err, statusErr) && statusErr.StatusCode == http.StatusNotFound { + return nil + } + + return fmt.Errorf("delete record request: %w", err) + } + + return nil +} + +// 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}}}} + + 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 c.createRRSet(ctx, zone, recordName, record) +} + +// 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) +} + +// 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) 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) + } + } + + 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)) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + + 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 + } + + return json.NewDecoder(resp.Body).Decode(dest) +} diff --git a/providers/dns/gcore/internal/client_test.go b/providers/dns/gcore/internal/client_test.go new file mode 100644 index 00000000..24a253c9 --- /dev/null +++ b/providers/dns/gcore/internal/client_test.go @@ -0,0 +1,256 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testToken = "test" + testRecordContent = "acme" + testRecordContent2 = "foo" + testTTL = 10 +) + +func setupTest(t *testing.T) (*http.ServeMux, *Client) { + t.Helper() + + mux := http.NewServeMux() + + server := httptest.NewServer(mux) + 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), + }) + + zone, err := client.GetZone(context.Background(), "example.com") + require.NoError(t, err) + + assert.Equal(t, expected, zone) +} + +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 TestClient_GetRRSet(t *testing.T) { + mux, client := setupTest(t) + + expected := RRSet{ + TTL: testTTL, + Records: []Records{ + {Content: []string{testRecordContent}}, + }, + } + + 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 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 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 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_AddRRSet(t *testing.T) { + testCases := []struct { + desc string + zone string + recordName string + value string + handledDomain string + handlers map[string]http.Handler + wantErr bool + }{ + { + 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}}}), + }, + }, + }, + { + 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) + } + }), + }, + }, + { + desc: "not in the zone", + zone: "test.example.com", + recordName: "notfound.example.com", + value: testRecordContent, + wantErr: true, + }, + } + + 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) + }) + } +} + +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 + } + + if req.Method != v.method { + http.Error(rw, "wrong method", http.StatusMethodNotAllowed) + return + } + + if v.next != nil { + v.next.ServeHTTP(rw, req) + } +} + +func handleAPIError() http.HandlerFunc { + return func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(rw).Encode(APIError{Message: "oops"}) + } +} + +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 + } + } +} + +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 00000000..4245f5ba --- /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) +}