diff --git a/OWNERS b/OWNERS index 19c341e70..ad564b75f 100644 --- a/OWNERS +++ b/OWNERS @@ -9,6 +9,7 @@ providers/digitalocean @Deraen providers/dnsimple @aeden providers/gandi_v5 @TomOnTime # providers/gcloud +providers/hedns @rblenkinsopp providers/hexonet @papakai providers/internetbs @pragmaton providers/inwx @svenpeter42 diff --git a/README.md b/README.md index aaedd6a3f..1c10986fd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Currently supported DNS providers: - Exoscale - Gandi - Google DNS + - Hurricane Electric DNS - HEXONET - Internet.bs - INWX diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 214a18d3a..0e21808c1 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -18,6 +18,7 @@
EXOSCALE
GANDI_V5
GCLOUD
+
HEDNS
HEXONET
INTERNETBS
INWX
@@ -74,6 +75,9 @@ + + + @@ -161,6 +165,9 @@ + + + @@ -242,6 +249,9 @@ + + + @@ -318,6 +328,9 @@ + + + @@ -374,6 +387,9 @@ + + + @@ -435,6 +451,9 @@ + + + @@ -503,6 +522,9 @@ + + + @@ -560,6 +582,9 @@ + + + @@ -616,6 +641,9 @@ + + + @@ -688,6 +716,9 @@ + + + @@ -744,6 +775,9 @@ + + + @@ -801,6 +835,9 @@ + + + @@ -840,6 +877,7 @@ + @@ -878,6 +916,7 @@ + @@ -916,6 +955,9 @@ + + + @@ -971,6 +1013,9 @@ + + + @@ -1045,6 +1090,9 @@ + + + @@ -1138,6 +1186,9 @@ + + + @@ -1210,6 +1261,9 @@ + + + diff --git a/docs/_providers/hedns.md b/docs/_providers/hedns.md new file mode 100644 index 000000000..8dd1d0fc5 --- /dev/null +++ b/docs/_providers/hedns.md @@ -0,0 +1,108 @@ +--- +name: Hurricane Electric DNS +title: Hurricane Electric DNS Provider +layout: default +jsId: HEDNS +--- +# Hurricane Electric DNS Provider + +## Important Note +Hurricane Electric does not currently expose an official JSON or XML API, and as such, this provider interacts directly +with the web interface. Because there is no officially supported API, this provider may cease to function if Hurricane +Electric changes their interface, and you should be willing to accept this possibility before relying on this provider. + +## Configuration +In your `creds.json` file you must provide your `dns.he.net` account username and password. These are the same username +and password used to login to the [web interface]([https://dns.he.net]). + +{% highlight json %} +{ + "hedns":{ + "username": "yourUsername", + "password": "yourPassword" + } +} +{% endhighlight %} + +### Two factor authentication + +If two-factor authentication has been enabled on your account you will also need to provide a valid TOTP code. +This can also be done via an environment variable: + +{% highlight json %} +{ + "hedns":{ + "username": "yourUsername", + "password": "yourPassword", + "totp": "$HEDNS_TOTP" + } +} +{% endhighlight %} + +and then you can run + +{% highlight bash %} +$ HEDNS_TOTP=12345 dnscontrol preview +{% endhighlight %} + +It is also possible to directly provide the shared TOTP secret using the key "totp-key" in `creds.json`. This secret is +only available when first enabling two-factor authentication. + +**Security Warning**: +* Anyone with access to this `creds.json` file will have *full* access to your Hurricane Electric account and will be + able to modify and delete your DNS entries +* Storing the shared secret together with the password weakens two factor authentication because both factors are stored + in a single place. + +{% highlight json %} +{ + "hedns":{ + "username": "yourUsername", + "password": "yourPassword", + "totp-key": "yourTOTPSharedSecret" + } +} +{% endhighlight %} + +### Persistent Sessions + +Normally this provider will refresh authentication with each run of dnscontrol. This can lead to issues when using +two-factor authentication if two runs occur within the time period of a single TOTP token (30 seconds), as reusing the +same token is explicitly disallowed by RFC 6238 (TOTP). + +To work around this limitation, if multiple requests need to be made, the option `"session-file-path"` can be set in +`creds.json`, which is the directory where a `.hedns-session` file will be created. This can be used to allow reuse of an +existing session between runs, without the need to re-authenticate. + +This option is disabled by default when this key is not present, + +**Security Warning**: +* Anyone with access to this `.hedns-session` file will be able to use the existing session (until it expires) and have + *full* access to your Hurrican Electric account and will be able to modify and delete your DNS entries. +* It should be stored in a location only trusted users can access. + +{% highlight json %} +{ + "hedns":{ + "username": "yourUsername", + "password": "yourPassword", + "totp-key": "yourTOTPSharedSecret" + "session-file-path": "." + } +} +{% endhighlight %} + + +## Metadata +This provider does not recognize any special metadata fields unique to Hurricane Electric DNS. + +## Usage +Example Javascript: + +{% highlight js %} +var DNSIMPLE = NewDnsProvider("hedns", "HEDNS"); + +D("example.tld", REG_DNSIMPLE, DnsProvider(HEDNS), + A("test","1.2.3.4") +); +{% endhighlight %} diff --git a/docs/provider-list.md b/docs/provider-list.md index 2f9c5f8e2..56bd1703b 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -78,6 +78,7 @@ Maintainers of contributed providers: * `DNSIMPLE` @aeden * `EXOSCALE` @pierre-emmanuelJ * `GANDI_V5` @TomOnTime +* `HEDNS` @rblenkinsopp * `HEXONET` @papakai * `INTERNETBS` @pragmaton * `INWX` @svenpeter42 diff --git a/go.mod b/go.mod index d62d8350d..5305801d2 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,10 @@ require ( github.com/Azure/go-autorest/autorest/azure/auth v0.5.0 github.com/Azure/go-autorest/autorest/to v0.4.0 github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 + github.com/PuerkitoBio/goquery v1.5.1 github.com/TomOnTime/utfutil v0.0.0-20200626160131-0b0178852c8f github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 + github.com/andybalholm/cascadia v1.2.0 // indirect github.com/aws/aws-sdk-go v1.32.10 github.com/billputer/go-namecheap v0.0.0-20170915210158-0c7adb0710f8 github.com/cenkalti/backoff v2.1.1+incompatible // indirect @@ -47,8 +49,7 @@ require ( github.com/tiramiseb/go-gandi v0.0.0-20200313161345-6b74caa58663 github.com/urfave/cli/v2 v2.2.0 github.com/vultr/govultr v0.2.0 - golang.org/x/mod v0.3.0 // indirect - golang.org/x/net v0.0.0-20200625001655-4c5254603344 + golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/tools v0.0.0-20200811215021-48a8ffc5b207 // indirect google.golang.org/api v0.28.0 diff --git a/go.sum b/go.sum index fb01fa798..3b6d291be 100644 --- a/go.sum +++ b/go.sum @@ -49,11 +49,17 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55 h1:jbGlDKdzAZ92NzK65hUP98ri0/r50vVVvmZsFP/nIqo= github.com/DisposaBoy/JsonConfigReader v0.0.0-20171218180944-5ea4d0ddac55/go.mod h1:GCzqZQHydohgVLSIqRKZeTt8IGb1Y4NaFfim3H40uUI= +github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/TomOnTime/utfutil v0.0.0-20200626160131-0b0178852c8f h1:MXp+2PP1RxWWoE3qmOecVblerzKCryXkFXq9er+EDr8= github.com/TomOnTime/utfutil v0.0.0-20200626160131-0b0178852c8f/go.mod h1:FiuynIwe98RFhWI8nZ0dnsldPVsBy9rHH1hn2WYwme4= github.com/alecthomas/kong v0.2.2/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= +github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go v1.32.10 h1:cEJTxGcBGlsM2tN36MZQKhlK93O9HrnaRs+lq2f0zN8= @@ -193,8 +199,6 @@ github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/vault/api v1.0.4 h1:j08Or/wryXT4AcHj1oCbMd7IijXcKzYUGw59LGu9onU= github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q= github.com/hashicorp/vault/sdk v0.1.13 h1:mOEPeOhT7jl0J4AMl1E705+BcmeRs1VmKNb9F0sMLy8= @@ -307,7 +311,6 @@ github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2 github.com/vultr/govultr v0.2.0 h1:CZSNNCk+PHz9hzmfH2PFGkDgc3qNetwZqtcaqL8shlg= github.com/vultr/govultr v0.2.0/go.mod h1:glSLa57Jdj5s860EEc6+DEBbb/t3aUOKnB4gVPmDVlQ= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= @@ -356,6 +359,7 @@ golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -379,6 +383,8 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc h1:zK/HqS5bZxDptfPJNq8v7vJfXtkU7r9TLIoSr1bXaP4= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= @@ -396,6 +402,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -468,8 +475,6 @@ golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200626032829-bcbc01e07a20 h1:q+ysxVHVQNTVHgzwjuk4ApAILRbfOLARfnEaqCIBR6A= -golang.org/x/tools v0.0.0-20200626032829-bcbc01e07a20/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200811215021-48a8ffc5b207 h1:8Kg+JssU1jBZs8GIrL5pl4nVyaqyyhdmHAR4D1zGErg= golang.org/x/tools v0.0.0-20200811215021-48a8ffc5b207/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/integrationTest/integration_test.go b/integrationTest/integration_test.go index 52489e62f..f21506482 100644 --- a/integrationTest/integration_test.go +++ b/integrationTest/integration_test.go @@ -438,7 +438,7 @@ func ignoreName(name string) *rec { func ignoreTarget(name string, typ string) *rec { r := &rec{ - Type: "IGNORE_TARGET", + Type: "IGNORE_TARGET", Target: typ, } r.SetLabel(name, "**current-domain**") @@ -509,16 +509,16 @@ func tc(desc string, recs ...*rec) *TestCase { } else if r.Type == "IGNORE_TARGET" { ignoredTargets = append(ignoredTargets, &models.IgnoreTarget{ Pattern: r.GetLabel(), - Type: r.Target, + Type: r.Target, }) } else { records = append(records, r) } } return &TestCase{ - Desc: desc, - Records: records, - IgnoredNames: ignoredNames, + Desc: desc, + Records: records, + IgnoredNames: ignoredNames, IgnoredTargets: ignoredTargets, } } @@ -614,6 +614,10 @@ func makeTests(t *testing.T) []*TestGroup { tc("Delete one", a("@", "1.2.3.4").ttl(500), a("www", "5.6.7.8").ttl(400)), tc("Add back and change ttl", a("www", "5.6.7.8").ttl(700), a("www", "1.2.3.4").ttl(700)), tc("Change targets and ttls", a("www", "1.1.1.1"), a("www", "2.2.2.2")), + ), + + testgroup("WildcardACD", + not("HEDNS"), // Not supported by dns.he.net due to abuse tc("Create wildcard", a("*", "1.2.3.4"), a("www", "1.1.1.1")), tc("Delete wildcard", a("www", "1.1.1.1")), ), @@ -641,7 +645,7 @@ func makeTests(t *testing.T) []*TestGroup { ), testgroup("Null MX", - not("AZURE_DNS", "GANDI_V5", "INWX", "NAMEDOTCOM", "DIGITALOCEAN", "NETCUP", "DNSIMPLE"), // These providers don't support RFC 7505 + not("AZURE_DNS", "GANDI_V5", "INWX", "NAMEDOTCOM", "DIGITALOCEAN", "NETCUP", "DNSIMPLE", "HEDNS"), // These providers don't support RFC 7505 tc("Null MX", mx("@", 0, ".")), ), diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 8c7bc3876..da457fd0a 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -62,6 +62,13 @@ "project_id": "$GCLOUD_PROJECT", "type": "$GCLOUD_TYPE" }, + "HEDNS": { + "username": "$HEDNS_USERNAME", + "password": "$HEDNS_PASSWORD", + "totp-key": "$HEDNS_TOTP_SECRET", + "session-file-path": ".", + "domain": "$HEDNS_DOMAIN" + }, "HEXONET": { "apientity": "$HEXONET_ENTITY", "apilogin": "$HEXONET_UID", diff --git a/providers/_all/all.go b/providers/_all/all.go index 878fe4ac6..e7aa7cde7 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -15,6 +15,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/exoscale" _ "github.com/StackExchange/dnscontrol/v3/providers/gandi_v5" _ "github.com/StackExchange/dnscontrol/v3/providers/gcloud" + _ "github.com/StackExchange/dnscontrol/v3/providers/hedns" _ "github.com/StackExchange/dnscontrol/v3/providers/hexonet" _ "github.com/StackExchange/dnscontrol/v3/providers/internetbs" _ "github.com/StackExchange/dnscontrol/v3/providers/inwx" diff --git a/providers/hedns/hednsProvider.go b/providers/hedns/hednsProvider.go new file mode 100644 index 000000000..8a193543b --- /dev/null +++ b/providers/hedns/hednsProvider.go @@ -0,0 +1,742 @@ +package hedns + +import ( + "crypto/sha1" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/providers" + "github.com/pquerna/otp/totp" +) + +/* +Hurricane Electric DNS provider (dns.he.net) + +Info required in `creds.json`: + - username + - password + +Either of the following settings is required when two factor authentication is enabled: + - totp (TOTP code if 2FA is enabled; best specified as an env variable) + - totp-key (shared TOTP secret used to generate a valid TOTP code; not recommended since + this effectively defeats the purpose of two factor authentication by storing + both factors at the same place) + +Additionally + - session-file-path (Path where a '.hedns-session' file will be created to allow a + session to persist between executions) + +*/ + +var features = providers.DocumentationNotes{ + providers.CanUseAlias: providers.Can(), + providers.CanUseCAA: providers.Can(), + providers.CanUseNAPTR: providers.Can(), + providers.CanUseDS: providers.Cannot(), + providers.CanUseDSForChildren: providers.Cannot(), + providers.CanUsePTR: providers.Can(), + providers.CanUseSSHFP: providers.Can(), + providers.CanUseSRV: providers.Can(), + providers.CanUseTLSA: providers.Cannot(), + providers.CanUseTXTMulti: providers.Can(), + providers.CanAutoDNSSEC: providers.Cannot(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), + providers.CanGetZones: providers.Can(), +} + +func init() { + providers.RegisterDomainServiceProviderType("HEDNS", newHDNSProvider, features) +} + +var defaultNameservers = []string{ + "ns1.he.net", + "ns2.he.net", + "ns3.he.net", + "ns4.he.net", + "ns5.he.net", +} + +const ( + apiEndpoint = "https://dns.he.net/" + sessionFileName = ".hedns-session" + + errorInvalidCredentials = "Incorrect" + errorInvalidTotpToken = "The token supplied is invalid." + errorTotpTokenRequired = "You must enter the token generated by your authenticator." + errorTotpTokenReused = "This token has already been used. You may not reuse tokens." + errorImproperDelegation = "This zone does not appear to be properly delegated to our nameservers." +) + +// HDNSProvider stores login credentials and represents and API connection +type HDNSProvider struct { + Username string + Password string + TfaSecret string + TfaValue string + SessionFilePath string + + httpClient http.Client +} + +// Record stores the HDNS specific zone and record IDs +type Record struct { + RecordName string + RecordID uint64 + ZoneName string + ZoneID uint64 +} + +func newHDNSProvider(cfg map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) { + username, password := cfg["username"], cfg["password"] + totpSecret, totpValue := cfg["totp-key"], cfg["totp"] + sessionFilePath := cfg["session-file-path"] + + if username == "" { + return nil, fmt.Errorf("username must be provided") + } + if password == "" { + return nil, fmt.Errorf("password must be provided") + } + if totpSecret != "" && totpValue != "" { + return nil, fmt.Errorf("totp and totp-key must not be specified at the same time") + } + + // Perform the initial login + client := &HDNSProvider{ + Username: username, + Password: password, + TfaSecret: totpSecret, + TfaValue: totpValue, + SessionFilePath: sessionFilePath, + } + + // Create storage for the cookies + cookieJar, _ := cookiejar.New(nil) + client.httpClient = http.Client{Jar: cookieJar} + + err := client.authenticate() + return client, err +} + +// ListZones list all zones on this provider. +func (c *HDNSProvider) ListZones() ([]string, error) { + domainsMap, err := c.listDomains() + if err != nil { + return nil, err + } + + domains := make([]string, 0, len(domainsMap)) + for domain := range domainsMap { + domains = append(domains, domain) + } + + // Ensure the order is deterministic + sort.Strings(domains) + + return domains, err +} + +// EnsureDomainExists creates the domain if it does not exist. +func (c *HDNSProvider) EnsureDomainExists(domain string) error { + domains, err := c.ListZones() + if err != nil { + return err + } + + for _, d := range domains { + if d == domain { + return nil + } + } + + return c.createDomain(domain) +} + +// GetNameservers returns the default HDNS nameservers. +func (c *HDNSProvider) GetNameservers(_ string) ([]*models.Nameserver, error) { + return models.ToNameservers(defaultNameservers) +} + +// GetDomainCorrections returns a list of corrections for the domain. +func (c *HDNSProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + var corrections []*models.Correction + + err := dc.Punycode() + if err != nil { + return nil, err + } + + records, err := c.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + + // Get the SOA record to get the ZoneID, then remove it from the list. + zoneID := uint64(0) + var prunedRecords models.Records + for _, r := range records { + if r.Type == "SOA" { + zoneID = r.Original.(Record).ZoneID + } else { + prunedRecords = append(prunedRecords, r) + } + } + + // Normalize + models.PostProcessRecords(prunedRecords) + + differ := diff.New(dc) + _, toCreate, toDelete, toModify, err := differ.IncrementalDiff(prunedRecords) + if err != nil { + return nil, err + } + + for _, del := range toDelete { + record := del.Existing + corrections = append(corrections, &models.Correction{ + Msg: del.String(), + F: func() error { return c.deleteZoneRecord(record) }, + }) + } + + for _, cre := range toCreate { + record := cre.Desired + record.Original = Record{ + ZoneName: dc.Name, + ZoneID: zoneID, + RecordName: cre.Desired.Name, + } + corrections = append(corrections, &models.Correction{ + Msg: cre.String(), + F: func() error { return c.editZoneRecord(record, true) }, + }) + } + + for _, mod := range toModify { + record := mod.Desired + record.Original = Record{ + ZoneName: dc.Name, + ZoneID: zoneID, + RecordID: mod.Existing.Original.(Record).RecordID, + RecordName: mod.Desired.Name, + } + corrections = append(corrections, &models.Correction{ + Msg: mod.String(), + F: func() error { return c.editZoneRecord(record, false) }, + }) + } + + return corrections, err +} + +// GetZoneRecords returns all the records for the given domain +func (c *HDNSProvider) GetZoneRecords(domain string) (models.Records, error) { + var zoneRecords []*models.RecordConfig + + // Get Domain ID + domains, err := c.listDomains() + if err != nil { + return nil, err + } + + domainID, domainExists := domains[domain] + if !domainExists { + return nil, fmt.Errorf("domain %s does not exist", domain) + } + + queryURL, _ := url.Parse(apiEndpoint) + q := queryURL.Query() + q.Add("hosted_dns_zoneid", strconv.FormatUint(domainID, 10)) + q.Add("menu", "edit_zone") + q.Add("hosted_dns_editzone", "") + queryURL.RawQuery = q.Encode() + + response, err := c.httpClient.Get(queryURL.String()) + if err != nil { + return nil, err + } + defer response.Body.Close() + + // Parse the HTML response + document, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + return nil, err + } + + // Check we can find the zone records + if document.Find("#dns_main_content").Size() == 0 { + return nil, fmt.Errorf("zone records listing failed") + } + + // Load all the domain records + recordSelector := "tr.dns_tr, tr.dns_tr_dynamic, tr.dns_tr_locked" + document.Find(recordSelector).EachWithBreak(func(index int, element *goquery.Selection) bool { + parser := elementParser{} + + rc := &models.RecordConfig{ + Type: parser.parseStringAttr(element.Find("td > .rrlabel"), "data"), + TTL: uint32(parser.parseIntElement(element.Find("td:nth-child(5)"))), + Original: Record{ + ZoneName: domain, + ZoneID: domainID, + RecordName: parser.parseStringElement(element.Find(".dns_view")), + RecordID: parser.parseIntAttr(element, "id"), + }, + Target: parser.parseStringAttr(element.Find("td:nth-child(7)"), "data"), + } + + priority := parser.parseIntElement(element.Find("td:nth-child(6)")) + if parser.err != nil { + err = parser.err + return false + } + + // Ignore record types that dnscontrol does not support + if rc.Type == "HINFO" || rc.Type == "AFSDB" || rc.Type == "RP" || rc.Type == "LOC" { + return true + } + + rc.SetLabelFromFQDN(rc.Original.(Record).RecordName, domain) + + // dns.he.net omits the trailing "." on the hostnames for certain record types + if rc.Type == "CNAME" || rc.Type == "MX" || rc.Type == "NS" || rc.Type == "PTR" { + rc.Target += "." + } + + switch rc.Type { + case "ALIAS": + err = rc.SetTarget(rc.Target) + case "MX": + err = rc.SetTargetMX(uint16(priority), rc.Target) + case "SRV": + err = rc.SetTargetSRVPriorityString(uint16(priority), rc.Target) + case "SPF": + // Convert to TXT record as SPF is deprecated + rc.Type = "TXT" + fallthrough + default: + err = rc.PopulateFromString(rc.Type, rc.Target, domain) + } + + if err != nil { + return false + } + + zoneRecords = append(zoneRecords, rc) + return true + }) + + return zoneRecords, err +} + +func (c *HDNSProvider) authResumeSession() (authenticated bool, requiresTfa bool, err error) { + response, err := c.httpClient.Get(apiEndpoint) + if err != nil { + return false, false, err + } + defer response.Body.Close() + + document, err := c.parseResponseForDocumentAndErrors(response) + if err != nil { + // Deal with the edge case where we have attempted to use the same authentication token more than two times + if err.Error() == errorTotpTokenRequired { + return false, true, nil + } + return false, false, err + } + + // Look for the presence of the login button or the TFA input + authenticated = document.Find("#_tlogout").Size() > 0 + requiresTfa = document.Find("input#tfacode").Size() > 0 + + return authenticated, requiresTfa, err +} + +func (c *HDNSProvider) authUsernameAndPassword() (authenticated bool, requiresTfa bool, err error) { + // Login with username and password + response, err := c.httpClient.PostForm(apiEndpoint, url.Values{ + "email": {c.Username}, + "pass": {c.Password}, + "submit": {"Login!"}, + }) + if err != nil { + return false, false, err + } + defer response.Body.Close() + + document, err := c.parseResponseForDocumentAndErrors(response) + if err != nil { + if err.Error() == errorInvalidCredentials { + err = fmt.Errorf("authentication failed with incorrect username or password") + } + if err.Error() == errorTotpTokenRequired { + return false, true, nil + } + return false, false, err + } + + authenticated = document.Find("#_tlogout").Size() > 0 + requiresTfa = document.Find("input#tfacode").Size() > 0 + + // Completed and 2FA is not required + return authenticated, requiresTfa, err +} + +func (c *HDNSProvider) auth2FA() (authenticated bool, err error) { + + if c.TfaValue == "" && c.TfaSecret == "" { + return false, fmt.Errorf("account requires two-factor authentication but neither totp or totp-key were provided") + } + + if c.TfaValue == "" && c.TfaSecret != "" { + var err error + c.TfaValue, err = totp.GenerateCode(c.TfaSecret, time.Now()) + if err != nil { + return false, err + } + } + + response, err := c.httpClient.PostForm(apiEndpoint, url.Values{ + "tfacode": {c.TfaValue}, + "submit": {"Submit"}, + }) + if err != nil { + return false, err + } + defer response.Body.Close() + + document, err := c.parseResponseForDocumentAndErrors(response) + if err != nil { + switch err.Error() { + case errorInvalidTotpToken: + err = fmt.Errorf("invalid TOTP token value") + case errorTotpTokenReused: + err = fmt.Errorf("TOTP token was reused within its period (30 seconds)") + } + return false, err + } + authenticated = document.Find("#_tlogout").Size() > 0 + + return authenticated, err +} + +func (c *HDNSProvider) authenticate() error { + + if c.SessionFilePath != "" { + _ = c.loadSessionFile() + } + + authenticated, requiresTfa, err := c.authResumeSession() + if err != nil { + return err + } + + if !authenticated { + // Only perform username and password login if two-factor authentication is not required at this stage + if !requiresTfa { + authenticated, requiresTfa, err = c.authUsernameAndPassword() + if err != nil { + return err + } + } + + // Only perform two-factor authentication if required + if requiresTfa { + authenticated, err = c.auth2FA() + if err != nil { + return err + } + } + } + + if !authenticated { + err = fmt.Errorf("unknown authentication failure") + } else { + if c.SessionFilePath != "" { + err = c.saveSessionFile() + } + } + + return err +} + +func (c *HDNSProvider) listDomains() (map[string]uint64, error) { + response, err := c.httpClient.Get(apiEndpoint) + if err != nil { + return nil, err + } + defer response.Body.Close() + + document, err := goquery.NewDocumentFromReader(response.Body) + if err != nil { + return nil, err + } + + // Check we can list domains + if document.Find("#domains_table").Size() == 0 { + return nil, fmt.Errorf("domain listing failed") + } + + // Find all the forward & reverse domains + domains := make(map[string]uint64) + recordsSelector := strings.Join([]string{ + "#domains_table > tbody > tr > td:last-child > img", // Forward records + "#tabs-advanced .generic_table > tbody > tr > td:last-child > img", // Reverse records + }, ", ") + + document.Find(recordsSelector).EachWithBreak(func(index int, element *goquery.Selection) bool { + domainID, idExists := element.Attr("value") + domainName, nameExists := element.Attr("name") + if idExists && nameExists { + domains[domainName], err = strconv.ParseUint(domainID, 10, 64) + return err == nil + } + return true + }) + + return domains, err +} + +func (c *HDNSProvider) createDomain(domain string) error { + values := url.Values{ + "action": {"add_zone"}, + "retmain": {"0"}, + "add_domain": {domain}, + "submit": {"Add Domain!"}, + } + + response, err := c.httpClient.PostForm(apiEndpoint, values) + if err != nil { + return err + } + defer response.Body.Close() + + _, err = c.parseResponseForDocumentAndErrors(response) + return err +} + +func (c *HDNSProvider) editZoneRecord(rc *models.RecordConfig, create bool) error { + values := url.Values{ + "account": {}, + "menu": {"edit_zone"}, + "hosted_dns_zoneid": {strconv.FormatUint(rc.Original.(Record).ZoneID, 10)}, + "hosted_dns_editzone": {"1"}, + "TTL": {strconv.FormatUint(uint64(rc.TTL), 10)}, + "Name": {rc.Name}, + } + + // Select the correct mode and deal with the quirks + if create { + values.Set("Type", rc.Type) + values.Set("hosted_dns_editrecord", "Submit") + values.Set("hosted_dns_recordid", "") + } else { + values.Set("Type", strings.ToLower(rc.Type)) // Lowercase on update + values.Set("hosted_dns_editrecord", "Update") + values.Set("hosted_dns_recordid", strconv.FormatUint(rc.Original.(Record).RecordID, 10)) + } + + // Handle priorities + if create { + values.Set("Priority", "") + } else { + values.Set("Priority", "-") + } + + // Work out the content + switch rc.Type { + case "MX": + values.Set("Priority", strconv.FormatUint(uint64(rc.MxPreference), 10)) + values.Set("Content", rc.Target) + case "SRV": + values.Del("Content") + values.Set("Target", rc.Target) + values.Set("Priority", strconv.FormatUint(uint64(rc.SrvPriority), 10)) + values.Set("Weight", strconv.FormatUint(uint64(rc.SrvWeight), 10)) + values.Set("Port", strconv.FormatUint(uint64(rc.SrvPort), 10)) + default: + values.Set("Content", rc.GetTargetCombined()) + } + + response, err := c.httpClient.PostForm(apiEndpoint, values) + if err != nil { + return err + } + defer response.Body.Close() + + _, err = c.parseResponseForDocumentAndErrors(response) + return err +} + +func (c *HDNSProvider) deleteZoneRecord(rc *models.RecordConfig) error { + values := url.Values{ + "menu": {"edit_zone"}, + "hosted_dns_zoneid": {strconv.FormatUint(rc.Original.(Record).ZoneID, 10)}, + "hosted_dns_recordid": {strconv.FormatUint(rc.Original.(Record).RecordID, 10)}, + "hosted_dns_editzone": {"1"}, + "hosted_dns_delrecord": {"1"}, + "hosted_dns_delconfirm": {"delete"}, + } + + response, err := c.httpClient.PostForm(apiEndpoint, values) + if err != nil { + return err + } + defer response.Body.Close() + + _, err = c.parseResponseForDocumentAndErrors(response) + return err +} + +func (c *HDNSProvider) generateCredentialHash() string { + hash := sha1.New() + hash.Write([]byte(c.Username)) + hash.Write([]byte(c.Password)) + hash.Write([]byte(c.TfaSecret)) + return fmt.Sprintf("%x", hash.Sum(nil)) +} + +func (c *HDNSProvider) saveSessionFile() error { + cookieDomain, err := url.Parse(apiEndpoint) + if err != nil { + return err + } + + // Put the credential hash on the first lines + entries := []string{ + c.generateCredentialHash(), + } + + for _, cookie := range c.httpClient.Jar.Cookies(cookieDomain) { + entries = append(entries, strings.Join([]string{cookie.Name, cookie.Value}, "=")) + } + + fileName := path.Join(c.SessionFilePath, sessionFileName) + err = ioutil.WriteFile(fileName, []byte(strings.Join(entries, "\n")), 0600) + return err +} + +func (c *HDNSProvider) loadSessionFile() error { + cookieDomain, err := url.Parse(apiEndpoint) + if err != nil { + return err + } + + fileName := path.Join(c.SessionFilePath, sessionFileName) + bytes, err := ioutil.ReadFile(fileName) + if err != nil { + if os.IsNotExist(err) { + // Skip loading the session. + return nil + } + return err + } + + var cookies []*http.Cookie + for i, entry := range strings.Split(string(bytes), "\n") { + if i == 0 { + if entry != c.generateCredentialHash() { + return fmt.Errorf("invalid credential hash in session file") + } + } else { + kv := strings.Split(entry, "=") + if len(kv) == 2 { + cookies = append(cookies, &http.Cookie{ + Name: kv[0], + Value: kv[1], + }) + } + } + } + c.httpClient.Jar.SetCookies(cookieDomain, cookies) + + return err +} + +func (c *HDNSProvider) parseResponseForDocumentAndErrors(response *http.Response) (document *goquery.Document, err error) { + var ignoredErrorMessages = [...]string{ + errorImproperDelegation, + } + + document, err = goquery.NewDocumentFromReader(response.Body) + if err != nil { + return nil, err + } + + // Check for any errors ignoring irrelevant errors + document.Find("div#dns_err").EachWithBreak(func(index int, element *goquery.Selection) bool { + errorMessage := element.Text() + for _, ignoredMessage := range ignoredErrorMessages { + if strings.Contains(errorMessage, ignoredMessage) { + return true + } + } + err = fmt.Errorf(element.Text()) + return false + }) + + return document, err +} + +type elementParser struct { + err error +} + +func (p *elementParser) parseStringAttr(element *goquery.Selection, attr string) (result string) { + if p.err != nil { + return + } + result, exists := element.Attr(attr) + if !exists { + p.err = fmt.Errorf("could not locate attribute %s", attr) + } + return result +} + +func (p *elementParser) parseIntAttr(element *goquery.Selection, attr string) (result uint64) { + if p.err != nil { + return + } + if value, exists := element.Attr(attr); exists { + result, p.err = strconv.ParseUint(value, 10, 64) + } else { + p.err = fmt.Errorf("could not locate attribute %s", attr) + } + return result +} + +func (p *elementParser) parseStringElement(element *goquery.Selection) (result string) { + if p.err != nil { + return + } + return element.Text() +} + +func (p *elementParser) parseIntElement(element *goquery.Selection) (result uint64) { + if p.err != nil { + return + } + + // Special case to deal with Priority + if element.Text() == "-" { + return 0 + } + + result, p.err = strconv.ParseUint(element.Text(), 10, 64) + return result +}