diff --git a/README.md b/README.md index df8ff104..5170d1a3 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ Detailed documentation is available [here](https://go-acme.github.io/lego/dns). | [Oracle Cloud](https://go-acme.github.io/lego/dns/oraclecloud/) | [OVH](https://go-acme.github.io/lego/dns/ovh/) | [PowerDNS](https://go-acme.github.io/lego/dns/pdns/) | [Rackspace](https://go-acme.github.io/lego/dns/rackspace/) | | [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/) | [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/) | [Vscale](https://go-acme.github.io/lego/dns/vscale/) | -| [Vultr](https://go-acme.github.io/lego/dns/vultr/) | [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/) | +| [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/) | [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 056eb636..8c40d784 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -92,6 +92,7 @@ func allDNSCodes() string { "transip", "vegadns", "versio", + "vinyldns", "vscale", "vultr", "yandex", @@ -1767,6 +1768,27 @@ func displayDNSHelp(name string) error { ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/versio`) + case "vinyldns": + // generated from: providers/dns/vinyldns/vinyldns.toml + ew.writeln(`Configuration for VinylDNS.`) + ew.writeln(`Code: 'vinyldns'`) + ew.writeln(`Since: 'v4.4.0'`) + ew.writeln() + + ew.writeln(`Credentials:`) + ew.writeln(` - "VINYLDNS_ACCESS_KEY": The VinylDNS API key`) + ew.writeln(` - "VINYLDNS_HOST": The VinylDNS API URL`) + ew.writeln(` - "VINYLDNS_SECRET_KEY": The VinylDNS API Secret key`) + ew.writeln() + + ew.writeln(`Additional Configuration:`) + ew.writeln(` - "VINYLDNS_POLLING_INTERVAL": Time between DNS propagation check`) + ew.writeln(` - "VINYLDNS_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) + ew.writeln(` - "VINYLDNS_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/vinyldns`) + case "vscale": // generated from: providers/dns/vscale/vscale.toml ew.writeln(`Configuration for Vscale.`) diff --git a/docs/content/dns/zz_gen_vinyldns.md b/docs/content/dns/zz_gen_vinyldns.md new file mode 100644 index 00000000..1375e7e7 --- /dev/null +++ b/docs/content/dns/zz_gen_vinyldns.md @@ -0,0 +1,68 @@ +--- +title: "VinylDNS" +date: 2019-03-03T16:39:46+01:00 +draft: false +slug: vinyldns +--- + + + + + +Since: v4.4.0 + +Configuration for [VinylDNS](https://www.vinyldns.io). + + + + +- Code: `vinyldns` + +Here is an example bash command using the VinylDNS provider: + +```bash +VINYLDNS_ACCESS_KEY=xxxxxx \ +VINYLDNS_SECRET_KEY=yyyyy \ +VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \ +lego --email myemail@example.com --dns vinyldns --domains my.example.org run +``` + + + + +## Credentials + +| Environment Variable Name | Description | +|-----------------------|-------------| +| `VINYLDNS_ACCESS_KEY` | The VinylDNS API key | +| `VINYLDNS_HOST` | The VinylDNS API URL | +| `VINYLDNS_SECRET_KEY` | The VinylDNS API Secret key | + +The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. +More information [here](/lego/dns/#configuration-and-credentials). + + +## Additional Configuration + +| Environment Variable Name | Description | +|--------------------------------|-------------| +| `VINYLDNS_POLLING_INTERVAL` | Time between DNS propagation check | +| `VINYLDNS_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | +| `VINYLDNS_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). + +The vinyldns integration makes use of dotted hostnames to ease permission management. +Users are required to have DELETE ACL level or zone admin permissions on the VinylDNS zone containing the target host. + + + +## More information + +- [API documentation](https://www.vinyldns.io/api/) +- [Go client](https://github.com/vinyldns/go-vinyldns) + + + + diff --git a/go.mod b/go.mod index d611931b..e777d976 100644 --- a/go.mod +++ b/go.mod @@ -42,9 +42,10 @@ require ( github.com/rainycape/memcache v0.0.0-20150622160815-1031fa0ce2f2 github.com/sacloud/libsacloud v1.36.2 github.com/stretchr/testify v1.7.0 - github.com/transip/gotransip/v6 v6.6.0 - github.com/urfave/cli v1.22.5 - github.com/vultr/govultr/v2 v2.4.0 + github.com/transip/gotransip/v6 v6.2.0 + github.com/urfave/cli v1.22.4 + github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 + github.com/vultr/govultr/v2 v2.0.0 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad golang.org/x/net v0.0.0-20210220033124-5f55cee0dc0d golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d diff --git a/go.sum b/go.sum index 0f8669d6..37d6f948 100644 --- a/go.sum +++ b/go.sum @@ -139,6 +139,8 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 h1:JVrqSeQfdhYRFk24TvhTZWU0q8lfCojxZQFi3Ou7+uY= github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b h1:/vQ+oYKu+JoyaMPDsv5FzwuL2wwWBgBbtj/YLCi4LuA= +github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b/go.mod h1:Xo4aNUOrJnVruqWQJBtW6+bTBDTniY8yZum5rF3b5jw= github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -215,8 +217,8 @@ github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrj github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-retryablehttp v0.6.8 h1:92lWxgpa+fF3FozM4B3UZtHZMJX8T5XT+TFdCxsPyWs= -github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-retryablehttp v0.6.6 h1:HJunrbHTDDbBb/ay4kxa1n+dLmttUlnP3V9oNE4hmsM= +github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -404,11 +406,16 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= +github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 h1:hp2CYQUINdZMHdvTdXtPOY2ainKl4IoMcpAXEf2xj3Q= +github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/gunit v1.0.4 h1:tpTjnuH7MLlqhoD21vRoMZbMIi5GmBsAJDFyF67GhZA= +github.com/smartystreets/gunit v1.0.4/go.mod h1:EH5qMBab2UclzXUcpR8b93eHsIlp9u+pDQIRp5DZNzQ= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= @@ -436,18 +443,20 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= -github.com/transip/gotransip/v6 v6.6.0 h1:dAHCTZzX98H6QE2kA4R9acAXu5RPPTwMSUFtpKZF3Nk= -github.com/transip/gotransip/v6 v6.6.0/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g= +github.com/transip/gotransip/v6 v6.2.0 h1:0Z+qVsyeiQdWfcAUeJyF0IEKAPvhJwwpwPi2WGtBIiE= +github.com/transip/gotransip/v6 v6.2.0/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g= github.com/uber-go/atomic v1.3.2 h1:Azu9lPBWRNKzYXSIwRfgRuDuS0YKsK4NFhiQv98gkxo= github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= -github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU= -github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA= +github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/vultr/govultr/v2 v2.4.0 h1:6ySGGAsoOann0lmVNkS8grLvbAT2iYWnO4R1RVYFg0A= -github.com/vultr/govultr/v2 v2.4.0/go.mod h1:U+dZLAmyGD62IGykgC9JYU/zQIOkIhf93nw6dJL/47M= +github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 h1:TFXGGMHmml4rs29PdPisC/aaCzOxUu1Vsh9on/IpUfE= +github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14/go.mod h1:RWc47jtnVuQv6+lY3c768WtXCas/Xi+U5UFc5xULmYg= +github.com/vultr/govultr/v2 v2.0.0 h1:+lAtqfWy3g9VwL7tT2Fpyad8Vv4MxOhT/NU8O5dk+EQ= +github.com/vultr/govultr/v2 v2.0.0/go.mod h1:2PsEeg+gs3p/Fo5Pw8F9mv+DUBEOlrNZ8GmCTGmhOhs= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= diff --git a/providers/dns/dns_providers.go b/providers/dns/dns_providers.go index b6b8fb50..c1cff2c6 100644 --- a/providers/dns/dns_providers.go +++ b/providers/dns/dns_providers.go @@ -83,6 +83,7 @@ import ( "github.com/go-acme/lego/v4/providers/dns/transip" "github.com/go-acme/lego/v4/providers/dns/vegadns" "github.com/go-acme/lego/v4/providers/dns/versio" + "github.com/go-acme/lego/v4/providers/dns/vinyldns" "github.com/go-acme/lego/v4/providers/dns/vscale" "github.com/go-acme/lego/v4/providers/dns/vultr" "github.com/go-acme/lego/v4/providers/dns/yandex" @@ -253,6 +254,8 @@ func NewDNSChallengeProviderByName(name string) (challenge.Provider, error) { return versio.NewDNSProvider() case "vultr": return vultr.NewDNSProvider() + case "vinyldns": + return vinyldns.NewDNSProvider() case "vscale": return vscale.NewDNSProvider() case "yandex": diff --git a/providers/dns/vinyldns/fixtures/recordSetChange-create.json b/providers/dns/vinyldns/fixtures/recordSetChange-create.json new file mode 100644 index 00000000..0b653b66 --- /dev/null +++ b/providers/dns/vinyldns/fixtures/recordSetChange-create.json @@ -0,0 +1,40 @@ +{ + "changeType": "Create", + "created": "2021-03-04T00:49:00Z", + "id": "27ba5c17-a217-4e8d-b662-b1dc8bee588f", + "recordSet": { + "account": "", + "created": "2021-03-04T00:49:00Z", + "id": "10000000-0000-0000-0000-000000000000", + "name": "_acme-challenge.host", + "records": [ + { + "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" + } + ], + "status": "Active", + "ttl": 30, + "type": "TXT", + "updated": "2021-03-04T00:49:00Z", + "zoneId": "00000000-0000-0000-0000-000000000000" + }, + "singleBatchChangeIds": [], + "status": "Complete", + "userId": "50000000-0000-0000-0000-000000000000", + "zone": { + "account": "system", + "acl": { + "rules": [] + }, + "adminGroupId": "40000000-0000-0000-0000-000000000000", + "created": "2020-07-15T21:15:36Z", + "email": "Ops@company.invalid", + "id": "00000000-0000-0000-0000-000000000000", + "isTest": false, + "latestSync": "2020-07-15T21:15:36Z", + "name": "example.com.", + "shared": false, + "status": "Active", + "updated": "2021-03-03T18:02:47Z" + } +} diff --git a/providers/dns/vinyldns/fixtures/recordSetChange-delete.json b/providers/dns/vinyldns/fixtures/recordSetChange-delete.json new file mode 100644 index 00000000..f4ff5c13 --- /dev/null +++ b/providers/dns/vinyldns/fixtures/recordSetChange-delete.json @@ -0,0 +1,40 @@ +{ + "changeType": "Delete", + "created": "2021-03-04T00:49:00Z", + "id": "27ba5c17-a217-4e8d-b662-b1dc8bee588f", + "recordSet": { + "account": "", + "created": "2021-03-04T00:49:00Z", + "id": "10000000-0000-0000-0000-000000000000", + "name": "_acme-challenge.host", + "records": [ + { + "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" + } + ], + "status": "Active", + "ttl": 30, + "type": "TXT", + "updated": "2021-03-04T00:49:00Z", + "zoneId": "00000000-0000-0000-0000-000000000000" + }, + "singleBatchChangeIds": [], + "status": "Complete", + "userId": "50000000-0000-0000-0000-000000000000", + "zone": { + "account": "system", + "acl": { + "rules": [] + }, + "adminGroupId": "40000000-0000-0000-0000-000000000000", + "created": "2020-07-15T21:15:36Z", + "email": "Ops@company.invalid", + "id": "00000000-0000-0000-0000-000000000000", + "isTest": false, + "latestSync": "2020-07-15T21:15:36Z", + "name": "example.com.", + "shared": false, + "status": "Active", + "updated": "2021-03-03T18:02:47Z" + } +} diff --git a/providers/dns/vinyldns/fixtures/recordSetDelete.json b/providers/dns/vinyldns/fixtures/recordSetDelete.json new file mode 100644 index 00000000..3ac80d83 --- /dev/null +++ b/providers/dns/vinyldns/fixtures/recordSetDelete.json @@ -0,0 +1,39 @@ +{ + "changeType": "Delete", + "created": "2021-03-04T16:21:54Z", + "id": "20000000-0000-0000-0000-000000000000", + "recordSet": { + "account": "", + "created": "2021-03-04T16:21:54Z", + "id": "11000000-0000-0000-0000-000000000000", + "name": "_acme-challenge.host", + "records": [ + { + "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" + } + ], + "status": "Pending", + "ttl": 30, + "type": "TXT", + "zoneId": "00000000-0000-0000-0000-000000000000" + }, + "singleBatchChangeIds": [], + "status": "Pending", + "userId": "50000000-0000-0000-0000-000000000000", + "zone": { + "account": "system", + "acl": { + "rules": [] + }, + "adminGroupId": "40000000-0000-0000-0000-000000000000", + "created": "2020-07-15T21:15:36Z", + "email": "Ops@company.invalid", + "id": "00000000-0000-0000-0000-000000000000", + "isTest": false, + "latestSync": "2020-07-15T21:15:36Z", + "name": "example.com.", + "shared": false, + "status": "Active", + "updated": "2021-03-03T18:02:47Z" + } +} diff --git a/providers/dns/vinyldns/fixtures/recordSetUpdate-create.json b/providers/dns/vinyldns/fixtures/recordSetUpdate-create.json new file mode 100644 index 00000000..f165341c --- /dev/null +++ b/providers/dns/vinyldns/fixtures/recordSetUpdate-create.json @@ -0,0 +1,39 @@ +{ + "changeType": "Create", + "created": "2021-03-04T16:21:54Z", + "id": "20000000-0000-0000-0000-000000000000", + "recordSet": { + "account": "", + "created": "2021-03-04T16:21:54Z", + "id": "11000000-0000-0000-0000-000000000000", + "name": "_acme-challenge.host", + "records": [ + { + "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" + } + ], + "status": "Pending", + "ttl": 30, + "type": "TXT", + "zoneId": "00000000-0000-0000-0000-000000000000" + }, + "singleBatchChangeIds": [], + "status": "Pending", + "userId": "50000000-0000-0000-0000-000000000000", + "zone": { + "account": "system", + "acl": { + "rules": [] + }, + "adminGroupId": "40000000-0000-0000-0000-000000000000", + "created": "2020-07-15T21:15:36Z", + "email": "Ops@company.invalid", + "id": "00000000-0000-0000-0000-000000000000", + "isTest": false, + "latestSync": "2020-07-15T21:15:36Z", + "name": "example.com.", + "shared": false, + "status": "Active", + "updated": "2021-03-03T18:02:47Z" + } +} diff --git a/providers/dns/vinyldns/fixtures/recordSetsListAll-empty.json b/providers/dns/vinyldns/fixtures/recordSetsListAll-empty.json new file mode 100644 index 00000000..0298c470 --- /dev/null +++ b/providers/dns/vinyldns/fixtures/recordSetsListAll-empty.json @@ -0,0 +1,6 @@ +{ + "maxItems": 100, + "nameSort": "ASC", + "recordNameFilter": "_acme-challenge.host", + "recordSets": [] +} diff --git a/providers/dns/vinyldns/fixtures/recordSetsListAll.json b/providers/dns/vinyldns/fixtures/recordSetsListAll.json new file mode 100644 index 00000000..bc1c541f --- /dev/null +++ b/providers/dns/vinyldns/fixtures/recordSetsListAll.json @@ -0,0 +1,25 @@ +{ + "maxItems": 100, + "nameSort": "ASC", + "recordNameFilter": "_acme-challenge.host", + "recordSets": [ + { + "accessLevel": "Delete", + "account": "", + "created": "2021-03-04T00:51:43Z", + "fqdn": "_acme-challenge.host.example.com.", + "id": "30000000-0000-0000-0000-000000000000", + "name": "_acme-challenge.host", + "records": [ + { + "text": "O2UTPYgIzRNt5N27EVcNKDxv6goSF7ru3zi3chZXKUw" + } + ], + "status": "Active", + "ttl": 30, + "type": "TXT", + "updated": "2021-03-04T00:51:43Z", + "zoneId": "00000000-0000-0000-0000-000000000000" + } + ] +} diff --git a/providers/dns/vinyldns/fixtures/zoneByName.json b/providers/dns/vinyldns/fixtures/zoneByName.json new file mode 100644 index 00000000..d155839f --- /dev/null +++ b/providers/dns/vinyldns/fixtures/zoneByName.json @@ -0,0 +1,19 @@ +{ + "zone": { + "accessLevel": "Delete", + "account": "system", + "acl": { + "rules": [] + }, + "adminGroupId": "40000000-0000-0000-0000-000000000000", + "adminGroupName": "OpsTeam", + "created": "2020-07-15T21:15:36Z", + "email": "Ops@company.invalid", + "id": "00000000-0000-0000-0000-000000000000", + "latestSync": "2020-07-15T21:15:36Z", + "name": "example.com.", + "shared": false, + "status": "Active", + "updated": "2021-03-03T18:02:47Z" + } +} diff --git a/providers/dns/vinyldns/mock_test.go b/providers/dns/vinyldns/mock_test.go new file mode 100644 index 00000000..836d2e62 --- /dev/null +++ b/providers/dns/vinyldns/mock_test.go @@ -0,0 +1,114 @@ +package vinyldns + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T) (*http.ServeMux, *DNSProvider) { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + config := NewDefaultConfig() + config.AccessKey = "foo" + config.SecretKey = "bar" + config.Host = server.URL + + p, err := NewDNSProviderConfig(config) + require.NoError(t, err) + + return mux, p +} + +type mockRouter struct { + debug bool + + mu sync.Mutex + routes map[string]map[string]http.HandlerFunc +} + +func newMockRouter() *mockRouter { + routes := map[string]map[string]http.HandlerFunc{ + http.MethodGet: {}, + http.MethodPost: {}, + http.MethodPut: {}, + http.MethodDelete: {}, + } + + return &mockRouter{ + routes: routes, + } +} + +func (h *mockRouter) Debug() *mockRouter { + h.debug = true + + return h +} + +func (h *mockRouter) Get(path string, statusCode int, filename string) *mockRouter { + h.add(http.MethodGet, path, statusCode, filename) + return h +} + +func (h *mockRouter) Post(path string, statusCode int, filename string) *mockRouter { + h.add(http.MethodPost, path, statusCode, filename) + return h +} + +func (h *mockRouter) Put(path string, statusCode int, filename string) *mockRouter { + h.add(http.MethodPut, path, statusCode, filename) + return h +} + +func (h *mockRouter) Delete(path string, statusCode int, filename string) *mockRouter { + h.add(http.MethodDelete, path, statusCode, filename) + return h +} + +func (h *mockRouter) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + h.mu.Lock() + defer h.mu.Unlock() + + if h.debug { + fmt.Println(req) + } + + rt := h.routes[req.Method] + if rt == nil { + http.NotFound(rw, req) + return + } + + hdl := rt[req.URL.Path] + if hdl == nil { + http.NotFound(rw, req) + return + } + + hdl(rw, req) +} + +func (h *mockRouter) add(method, path string, statusCode int, filename string) { + h.routes[method][path] = func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(statusCode) + + data, err := ioutil.ReadFile(fmt.Sprintf("./fixtures/%s.json", filename)) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + + rw.Header().Set("Content-Type", "application/json") + _, _ = rw.Write(data) + } +} diff --git a/providers/dns/vinyldns/vinyldns.go b/providers/dns/vinyldns/vinyldns.go new file mode 100644 index 00000000..9b4be610 --- /dev/null +++ b/providers/dns/vinyldns/vinyldns.go @@ -0,0 +1,291 @@ +// Package vinyldns implements a DNS provider for solving the DNS-01 challenge using VinylDNS. +package vinyldns + +import ( + "errors" + "fmt" + "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/platform/wait" + "github.com/vinyldns/go-vinyldns/vinyldns" +) + +// Environment variables names. +const ( + envNamespace = "VINYLDNS_" + + EnvAccessKey = envNamespace + "ACCESS_KEY" + EnvSecretKey = envNamespace + "SECRET_KEY" + EnvHost = envNamespace + "HOST" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" +) + +// Config is used to configure the creation of the DNSProvider. +type Config struct { + AccessKey string + SecretKey string + Host string + TTL int + PropagationTimeout time.Duration + PollingInterval time.Duration +} + +// NewDefaultConfig returns a default configuration for the DNSProvider. +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, 30), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second), + } +} + +// DNSProvider implements the challenge.Provider interface. +type DNSProvider struct { + client *vinyldns.Client + config *Config +} + +// NewDNSProvider returns a DNSProvider instance configured for VinylDNS. +// Credentials must be passed in the environment variables: +// VINYLDNS_ACCESS_KEY, VINYLDNS_SECRET_KEY, VINYLDNS_HOST. +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccessKey, EnvSecretKey, EnvHost) + if err != nil { + return nil, fmt.Errorf("vinyldns: %w", err) + } + + config := NewDefaultConfig() + config.AccessKey = values[EnvAccessKey] + config.SecretKey = values[EnvSecretKey] + config.Host = values[EnvHost] + + return NewDNSProviderConfig(config) +} + +// NewDNSProviderConfig return a DNSProvider instance configured for VinylDNS. +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, errors.New("vinyldns: the configuration of the VinylDNS DNS provider is nil") + } + + if config.AccessKey == "" || config.SecretKey == "" { + return nil, errors.New("vinyldns: credentials are missing") + } + + if config.Host == "" { + return nil, errors.New("vinyldns: host is missing") + } + + client := vinyldns.NewClient(vinyldns.ClientConfiguration{ + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + Host: config.Host, + UserAgent: "go-acme/lego", + }) + + client.HTTPClient.Timeout = 30 * time.Second + + return &DNSProvider{client: client, config: config}, nil +} + +// Present creates a TXT record to fulfill the dns-01 challenge. +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + existingRecord, err := d.getRecordSet(fqdn) + if err != nil { + return fmt.Errorf("vinyldns: %w", err) + } + + record := vinyldns.Record{Text: value} + + if existingRecord == nil || existingRecord.ID == "" { + err = d.createRecordSet(fqdn, []vinyldns.Record{record}) + if err != nil { + return fmt.Errorf("vinyldns: %w", err) + } + + return nil + } + + for _, i := range existingRecord.Records { + if i.Text == value { + return nil + } + } + + records := existingRecord.Records + records = append(records, record) + + err = d.updateRecordSet(existingRecord, records) + if err != nil { + return fmt.Errorf("vinyldns: %w", err) + } + + return nil +} + +// CleanUp removes the TXT record matching the specified parameters. +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + fqdn, value := dns01.GetRecord(domain, keyAuth) + + existingRecord, err := d.getRecordSet(fqdn) + if err != nil { + return fmt.Errorf("vinyldns: %w", err) + } + + if existingRecord == nil || existingRecord.ID == "" || len(existingRecord.Records) == 0 { + return nil + } + + var records []vinyldns.Record + for _, i := range existingRecord.Records { + if i.Text != value { + records = append(records, i) + } + } + + if len(records) == 0 { + err = d.deleteRecordSet(existingRecord) + if err != nil { + return fmt.Errorf("vinyldns: %w", err) + } + + return nil + } + + err = d.updateRecordSet(existingRecord, records) + if err != nil { + return fmt.Errorf("vinyldns: %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) getRecordSet(fqdn string) (*vinyldns.RecordSet, error) { + zoneName, hostName, err := splitDomain(fqdn) + if err != nil { + return nil, err + } + + zone, err := d.client.ZoneByName(zoneName) + if err != nil { + return nil, err + } + + allRecordSets, err := d.client.RecordSetsListAll(zone.ID, vinyldns.ListFilter{NameFilter: hostName}) + if err != nil { + return nil, err + } + + var recordSets []vinyldns.RecordSet + for _, i := range allRecordSets { + if i.Type == "TXT" { + recordSets = append(recordSets, i) + } + } + + switch { + case len(recordSets) > 1: + return nil, fmt.Errorf("ambiguous recordset definition of %s", fqdn) + case len(recordSets) == 1: + return &recordSets[0], nil + default: + return nil, nil + } +} + +func (d *DNSProvider) createRecordSet(fqdn string, records []vinyldns.Record) error { + zoneName, hostName, err := splitDomain(fqdn) + if err != nil { + return err + } + + zone, err := d.client.ZoneByName(zoneName) + if err != nil { + return err + } + + recordSet := vinyldns.RecordSet{ + Name: hostName, + ZoneID: zone.ID, + Type: "TXT", + TTL: d.config.TTL, + Records: records, + } + + resp, err := d.client.RecordSetCreate(&recordSet) + if err != nil { + return err + } + + return d.waitForChanges("CreateRS", resp) +} + +func (d *DNSProvider) updateRecordSet(recordSet *vinyldns.RecordSet, newRecords []vinyldns.Record) error { + operation := "delete" + if len(recordSet.Records) < len(newRecords) { + operation = "add" + } + + recordSet.Records = newRecords + recordSet.TTL = d.config.TTL + + resp, err := d.client.RecordSetUpdate(recordSet) + if err != nil { + return err + } + + return d.waitForChanges("UpdateRS - "+operation, resp) +} + +func (d *DNSProvider) deleteRecordSet(existingRecord *vinyldns.RecordSet) error { + resp, err := d.client.RecordSetDelete(existingRecord.ZoneID, existingRecord.ID) + if err != nil { + return err + } + + return d.waitForChanges("DeleteRS", resp) +} + +func (d *DNSProvider) waitForChanges(operation string, resp *vinyldns.RecordSetUpdateResponse) error { + return wait.For("vinyldns", d.config.PropagationTimeout, d.config.PollingInterval, + func() (bool, error) { + change, err := d.client.RecordSetChange(resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) + if err != nil { + return false, fmt.Errorf("failed to query change status: %w", err) + } + + if change.Status == "Complete" { + return true, nil + } + + return false, fmt.Errorf("waiting operation: %s, zoneID: %s, recordsetID: %s, changeID: %s", + operation, resp.Zone.ID, resp.RecordSet.ID, resp.ChangeID) + }, + ) +} + +// splitDomain splits the hostname from the authoritative zone, and returns both parts. +func splitDomain(fqdn string) (string, string, error) { + zone, err := dns01.FindZoneByFqdn(fqdn) + if err != nil { + return "", "", err + } + + host := dns01.UnFqdn(strings.TrimSuffix(fqdn, zone)) + + return zone, host, nil +} diff --git a/providers/dns/vinyldns/vinyldns.toml b/providers/dns/vinyldns/vinyldns.toml new file mode 100644 index 00000000..c0960249 --- /dev/null +++ b/providers/dns/vinyldns/vinyldns.toml @@ -0,0 +1,31 @@ +Name = "VinylDNS" +Description = '''''' +URL = "https://www.vinyldns.io" +Code = "vinyldns" +Since = "v4.4.0" + +Example = ''' +VINYLDNS_ACCESS_KEY=xxxxxx \ +VINYLDNS_SECRET_KEY=yyyyy \ +VINYLDNS_HOST=https://api.vinyldns.example.org:9443 \ +lego --email myemail@example.com --dns vinyldns --domains my.example.org run +''' + +Additional = ''' +The vinyldns integration makes use of dotted hostnames to ease permission management. +Users are required to have DELETE ACL level or zone admin permissions on the VinylDNS zone containing the target host. +''' + +[Configuration] + [Configuration.Credentials] + VINYLDNS_ACCESS_KEY = "The VinylDNS API key" + VINYLDNS_SECRET_KEY = "The VinylDNS API Secret key" + VINYLDNS_HOST = "The VinylDNS API URL" + [Configuration.Additional] + VINYLDNS_POLLING_INTERVAL = "Time between DNS propagation check" + VINYLDNS_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" + VINYLDNS_TTL = "The TTL of the TXT record used for the DNS challenge" + +[Links] + API = "https://www.vinyldns.io/api/" + GoClient = "https://github.com/vinyldns/go-vinyldns" diff --git a/providers/dns/vinyldns/vinyldns_test.go b/providers/dns/vinyldns/vinyldns_test.go new file mode 100644 index 00000000..1f7e7d5a --- /dev/null +++ b/providers/dns/vinyldns/vinyldns_test.go @@ -0,0 +1,244 @@ +package vinyldns + +import ( + "net/http" + "testing" + "time" + + "github.com/go-acme/lego/v4/platform/tester" + "github.com/stretchr/testify/require" +) + +const envDomain = envNamespace + "DOMAIN" + +const ( + targetRootDomain = "example.com" + targetDomain = "host." + targetRootDomain + zoneID = "00000000-0000-0000-0000-000000000000" + newRecordSetID = "11000000-0000-0000-0000-000000000000" + newCreateChangeID = "20000000-0000-0000-0000-000000000000" + recordID = "30000000-0000-0000-0000-000000000000" +) + +var envTest = tester.NewEnvTest( + EnvAccessKey, + EnvSecretKey, + EnvHost). + WithDomain(envDomain) + +func TestNewDNSProvider(t *testing.T) { + testCases := []struct { + desc string + envVars map[string]string + expected string + }{ + { + desc: "success", + envVars: map[string]string{ + EnvAccessKey: "123", + EnvSecretKey: "456", + EnvHost: "https://example.org", + }, + }, + { + desc: "missing all credentials", + envVars: map[string]string{ + EnvHost: "https://example.org", + }, + expected: "vinyldns: some credentials information are missing: VINYLDNS_ACCESS_KEY,VINYLDNS_SECRET_KEY", + }, + { + desc: "missing access key", + envVars: map[string]string{ + EnvSecretKey: "456", + EnvHost: "https://example.org", + }, + expected: "vinyldns: some credentials information are missing: VINYLDNS_ACCESS_KEY", + }, + { + desc: "missing secret key", + envVars: map[string]string{ + EnvAccessKey: "123", + EnvHost: "https://example.org", + }, + expected: "vinyldns: some credentials information are missing: VINYLDNS_SECRET_KEY", + }, + { + desc: "missing host", + envVars: map[string]string{ + EnvAccessKey: "123", + EnvSecretKey: "456", + }, + expected: "vinyldns: some credentials information are missing: VINYLDNS_HOST", + }, + } + + 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 + accessKey string + secretKey string + host string + expected string + }{ + { + desc: "success", + accessKey: "123", + secretKey: "456", + host: "https://example.org", + }, + { + desc: "missing all credentials", + host: "https://example.org", + expected: "vinyldns: credentials are missing", + }, + { + desc: "missing access key", + secretKey: "456", + host: "https://example.org", + expected: "vinyldns: credentials are missing", + }, + { + desc: "missing secret key", + accessKey: "123", + host: "https://example.org", + expected: "vinyldns: credentials are missing", + }, + { + desc: "missing host", + accessKey: "123", + secretKey: "456", + expected: "vinyldns: host is missing", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + config := NewDefaultConfig() + config.AccessKey = test.accessKey + config.SecretKey = test.secretKey + config.Host = test.host + + 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 TestDNSProvider_Present(t *testing.T) { + testCases := []struct { + desc string + keyAuth string + handler http.Handler + }{ + { + desc: "new record", + keyAuth: "123456d==", + handler: newMockRouter(). + Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). + Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll-empty"). + Post("/zones/"+zoneID+"/recordsets", http.StatusAccepted, "recordSetUpdate-create"). + Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"), + }, + { + desc: "existing record", + keyAuth: "123456d==", + handler: newMockRouter(). + Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). + Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"), + }, + { + desc: "duplicate key", + keyAuth: "abc123!!", + handler: newMockRouter(). + Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). + Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"). + Put("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetUpdate-create"). + Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-create"), + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + mux, p := setup(t) + mux.Handle("/", test.handler) + + err := p.Present(targetDomain, "token"+test.keyAuth, test.keyAuth) + require.NoError(t, err) + }) + } +} + +func TestDNSProvider_CleanUp(t *testing.T) { + mux, p := setup(t) + + mux.Handle("/", newMockRouter(). + Get("/zones/name/"+targetRootDomain+".", http.StatusOK, "zoneByName"). + Get("/zones/"+zoneID+"/recordsets", http.StatusOK, "recordSetsListAll"). + Delete("/zones/"+zoneID+"/recordsets/"+recordID, http.StatusAccepted, "recordSetDelete"). + Get("/zones/"+zoneID+"/recordsets/"+newRecordSetID+"/changes/"+newCreateChangeID, http.StatusOK, "recordSetChange-delete"), + ) + + err := p.CleanUp(targetDomain, "123456d==", "123456d==") + require.NoError(t, err) +} + +func TestLivePresent(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + err = provider.Present(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +} + +func TestLiveCleanUp(t *testing.T) { + if !envTest.IsLiveTest() { + t.Skip("skipping live test") + } + + envTest.RestoreEnv() + provider, err := NewDNSProvider() + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + err = provider.CleanUp(envTest.GetDomain(), "", "123d==") + require.NoError(t, err) +}