diff --git a/OWNERS b/OWNERS
index 9bd9cd4af..851fdff99 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..1bb60f091
--- /dev/null
+++ b/docs/_providers/hedns.md
@@ -0,0 +1,75 @@
+---
+name: Hurricane Electric DNS
+title: Hurricane Electric DNS Provider
+layout: default
+jsId: HEDNS
+---
+# Hurricane Electric DNS Provider
+## Configuration
+In your `creds.json` file you must provide your `dns.he.net` account username and password, along wit
+
+In your `creds.json` file you must provide your INWX
+username and password:
+
+{% 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.
+
+**Important Notes**:
+* Anyone with access to this `creds.json` file will have *full* access to your Hurrican 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 %}
+
+## 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 6d70502a7..0c545d0df 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..1f0b2c1d2 100644
--- a/integrationTest/integration_test.go
+++ b/integrationTest/integration_test.go
@@ -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..2a893c8c6 100644
--- a/integrationTest/providers.json
+++ b/integrationTest/providers.json
@@ -62,6 +62,12 @@
"project_id": "$GCLOUD_PROJECT",
"type": "$GCLOUD_TYPE"
},
+ "HEDNS": {
+ "username": "$HEDNS_USERNAME",
+ "password": "$HEDNS_PASSWORD",
+ "totp-secret": "$HEDNS_TOTP_SECRET",
+ "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..42ac53d38
--- /dev/null
+++ b/providers/hedns/hednsProvider.go
@@ -0,0 +1,616 @@
+package hedns
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "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)
+*/
+
+const ApiUrl = "https://dns.he.net/"
+
+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", NewProvider, features)
+}
+
+var defaultNameservers = []string{
+ "ns1.he.net",
+ "ns2.he.net",
+ "ns3.he.net",
+ "ns4.he.net",
+ "ns5.he.net",
+}
+
+type ApiClient struct {
+ Username string
+ Password string
+
+ TfaSecret string
+ TfaValue string
+
+ httpClient http.Client
+}
+
+type Record struct {
+ RecordName string
+ RecordId uint64
+ ZoneName string
+ ZoneId uint64
+}
+
+func NewProvider(cfg map[string]string, _ json.RawMessage) (providers.DNSServiceProvider, error) {
+ username, password := cfg["username"], cfg["password"]
+ totpSecret, totpValue := cfg["totp-key"], cfg["totp"]
+
+ 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 := NewApiClient(username, password, totpSecret)
+ err := client.authenticate()
+ return client, err
+}
+
+func NewApiClient(username, password, tfaSecret string) *ApiClient {
+ client := ApiClient{
+ Username: username,
+ Password: password,
+ TfaSecret: tfaSecret,
+ }
+
+ // Create storage for the cookies
+ cookieJar, _ := cookiejar.New(nil)
+ client.httpClient = http.Client{Jar: cookieJar}
+
+ return &client
+}
+
+func (c *ApiClient) ListZones() ([]string, error) {
+ domainsMap, err := c.listDomains()
+ if err != nil {
+ return nil, err
+ }
+
+ // Get the list of the domains
+ domains := make([]string, 0, len(domainsMap))
+ for _, key := range domains {
+ domains = append(domains, key)
+ }
+
+ return domains, err
+}
+
+func (c *ApiClient) 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)
+}
+
+func (c *ApiClient) GetNameservers(_ string) ([]*models.Nameserver, error) {
+ return models.ToNameservers(defaultNameservers)
+}
+
+func (c *ApiClient) 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 := differ.IncrementalDiff(prunedRecords)
+
+ 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
+}
+
+func (c *ApiClient) 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(ApiUrl)
+ 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()
+
+ document, err := goquery.NewDocumentFromReader(response.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ // 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{}
+
+ recordId := parser.parseIntAttr(element, "id")
+ recordName := parser.parseStringElement(element.Find(".dns_view"))
+ recordType := parser.parseStringAttr(element.Find("td > .rrlabel"), "data")
+ recordData := parser.parseStringAttr(element.Find("td:nth-child(7)"), "data")
+ recordPriority := parser.parseIntElement(element.Find("td:nth-child(6)"))
+ recordTtl := parser.parseIntElement(element.Find("td:nth-child(5)"))
+
+ if parser.err != nil {
+ err = parser.err
+ return false
+ }
+
+ // Ignore record types that dnscontrol does not support
+ if recordType == "HINFO" || recordType == "AFSDB" || recordType == "RP" || recordType == "LOC" {
+ return true
+ }
+
+ rc := &models.RecordConfig{
+ Type: recordType,
+ TTL: uint32(recordTtl),
+ Original: Record{
+ ZoneName: domain,
+ ZoneId: domainId,
+ RecordName: recordName,
+ RecordId: recordId,
+ },
+ }
+ rc.SetLabelFromFQDN(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" {
+ recordData += "."
+ }
+
+ switch rc.Type {
+ case "ALIAS":
+ err = rc.SetTarget(recordData)
+ case "MX":
+ err = rc.SetTargetMX(uint16(recordPriority), recordData)
+ case "SRV":
+ err = rc.SetTargetSRVPriorityString(uint16(recordPriority), recordData)
+ case "SPF":
+ // Convert to TXT record as SPF is deprecated
+ rc.Type = "TXT"
+ fallthrough
+ default:
+ err = rc.PopulateFromString(rc.Type, recordData, domain)
+ }
+
+ if err != nil {
+ return false
+ }
+
+ zoneRecords = append(zoneRecords, rc)
+ return true
+ })
+
+ return zoneRecords, err
+}
+
+func (c *ApiClient) checkResponseForErrors(response *http.Response) error {
+ document, err := goquery.NewDocumentFromReader(response.Body)
+ if err != nil {
+ return err
+ }
+
+ var ignoredErrorMessages = [...]string{
+ "This zone does not appear to be properly delegated to our nameservers.",
+ }
+
+ // 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 err
+}
+
+func (c *ApiClient) authGetCookie() error {
+ response, err := c.httpClient.Get(ApiUrl)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ return err
+}
+
+func (c *ApiClient) authUsernameAndPassword() (requiresTfa bool, err error) {
+ // Login with username and password
+ response, err := c.httpClient.PostForm(ApiUrl, url.Values{
+ "email": {c.Username},
+ "pass": {c.Password},
+ "submit": {"Login!"},
+ })
+ if err != nil {
+ return false, err
+ }
+ defer response.Body.Close()
+
+ if err = c.checkResponseForErrors(response); err != nil {
+ return false, err
+ }
+
+ // Check to see if a 2FA code is required.
+ document, err := goquery.NewDocumentFromReader(response.Body)
+ if err != nil {
+ return false, err
+ }
+
+ if document.Find("input#tfacode").Size() > 0 {
+ return true, nil
+ }
+
+ // Completed and 2FA is not required
+ return false, err
+}
+
+func (c *ApiClient) auth2FA() error {
+ if c.TfaValue == "" && c.TfaSecret != "" {
+ var err error
+ c.TfaValue, err = totp.GenerateCode(c.TfaSecret, time.Now())
+ if err != nil {
+ return err
+ }
+ }
+
+ response, err := c.httpClient.PostForm(ApiUrl, url.Values{
+ "tfacode": {c.TfaValue},
+ "submit": {"Submit"},
+ })
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ err = c.checkResponseForErrors(response)
+ return err
+}
+
+func (c *ApiClient) authenticate() error {
+ if err := c.authGetCookie(); err != nil {
+ return err
+ }
+
+ requiresTfa, err := c.authUsernameAndPassword()
+ if err != nil {
+ return err
+ }
+
+ if requiresTfa {
+ err = c.auth2FA()
+ if err != nil {
+ return err
+ }
+ }
+
+ return err
+}
+
+func (c *ApiClient) listDomains() (map[string]uint64, error) {
+ response, err := c.httpClient.Get(ApiUrl)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+
+ document, err := goquery.NewDocumentFromReader(response.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ 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
+ }, ", ")
+
+ // Find all the forward & reverse domains
+ 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 *ApiClient) createDomain(domain string) error {
+ values := url.Values{
+ "action": {"add_zone"},
+ "retmain": {"0"},
+ "add_domain": {domain},
+ "submit": {"Add Domain!"},
+ }
+
+ response, err := c.httpClient.PostForm(ApiUrl, values)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ err = c.checkResponseForErrors(response)
+ return err
+}
+
+func (c *ApiClient) 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(ApiUrl, values)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ err = c.checkResponseForErrors(response)
+ return err
+}
+
+func (c *ApiClient) 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(ApiUrl, values)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ err = c.checkResponseForErrors(response)
+ return err
+}
+
+func (c *ApiClient) setZoneDynamicKey(rc *models.RecordConfig, key string) error {
+ values := url.Values{
+ "account": {},
+ "menu": {"edit_zone"},
+ "hosted_dns_zoneid": {strconv.FormatUint(rc.Original.(Record).ZoneId, 10)},
+ "hosted_dns_recordid": {},
+ "hosted_dns_editzone": {"1"},
+ "Name": {rc.Original.(Record).RecordName},
+ "Key": {key},
+ "Key2": {key},
+ "generate_key": {"Submit"},
+ }
+
+ response, err := c.httpClient.PostForm(ApiUrl, values)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ err = c.checkResponseForErrors(response)
+ return 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
+}