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
+}