1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

NEW PROVIDER: HEDNS: Hurricane Electric DNS (dns.he.net) (#822)

* Add initial dns.he.net provider support

* Update to new IncrementalDiff interface

* Fix ListZones output for `all` query on `get-zones`

* Refactor authentication code for 2FA with better error checking

* Fix integration test and refactor zone record retrieval

* Add option to use `.hedns-session` file to store sessions between runs

* Add comment on `session-file-path`

* Add integration test for TXT records longer than 255 characters

* Add additional checks for expected responses, and better 2FA error checking

* Minor documentation changes

* Revert "Add integration test for TXT records longer than 255 characters"

This reverts commit 657272db

* Add note on provider fragility due to parsing the web-interface

* Resolve go lint issues

* Clarify security warnings in documentation
This commit is contained in:
Robert Blenkinsopp
2020-08-26 18:38:28 +01:00
committed by GitHub
parent 443c187dda
commit 74dd34443a
11 changed files with 938 additions and 13 deletions

1
OWNERS
View File

@ -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

View File

@ -27,6 +27,7 @@ Currently supported DNS providers:
- Exoscale
- Gandi
- Google DNS
- Hurricane Electric DNS
- HEXONET
- Internet.bs
- INWX

View File

@ -18,6 +18,7 @@
<th class="rotate"><div><span>EXOSCALE</span></div></th>
<th class="rotate"><div><span>GANDI_V5</span></div></th>
<th class="rotate"><div><span>GCLOUD</span></div></th>
<th class="rotate"><div><span>HEDNS</span></div></th>
<th class="rotate"><div><span>HEXONET</span></div></th>
<th class="rotate"><div><span>INTERNETBS</span></div></th>
<th class="rotate"><div><span>INWX</span></div></th>
@ -74,6 +75,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Actively maintained provider module.">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
</td>
@ -161,6 +165,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -242,6 +249,9 @@
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
@ -318,6 +328,9 @@
<i class="fa has-tooltip fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Using ALIAS is possible through our extended DNS (X-DNS) service. Feel free to get in touch with us.">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
</td>
@ -374,6 +387,9 @@
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="info" data-toggle="tooltip" data-container="body" data-placement="top" title="Supported by INWX but not implemented yet.">
@ -435,6 +451,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
@ -503,6 +522,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success" data-toggle="tooltip" data-container="body" data-placement="top" title="PTR records with empty targets are not supported">
<i class="fa has-tooltip fa-check text-success" aria-hidden="true"></i>
@ -560,6 +582,9 @@
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success">
@ -616,6 +641,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success" data-toggle="tooltip" data-container="body" data-placement="top" title="SRV records with empty targets are not supported">
<i class="fa has-tooltip fa-check text-success" aria-hidden="true"></i>
</td>
@ -688,6 +716,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success">
@ -744,6 +775,9 @@
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
@ -801,6 +835,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="INWX only supports a single entry for TXT records">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
@ -840,6 +877,7 @@
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger" data-toggle="tooltip" data-container="body" data-placement="top" title="Using ALIAS is possible through our extended DNS (X-DNS) service. Feel free to get in touch with us.">
<i class="fa has-tooltip fa-times text-danger" aria-hidden="true"></i>
</td>
@ -878,6 +916,7 @@
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -916,6 +955,9 @@
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td><i class="fa fa-minus dim"></i></td>
<td class="info" data-toggle="tooltip" data-container="body" data-placement="top" title="DS records are only supported at the apex and require a different API call that hasn&#39;t been implemented yet.">
@ -971,6 +1013,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td><i class="fa fa-minus dim"></i></td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
@ -1045,6 +1090,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -1138,6 +1186,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="danger">
<i class="fa fa-times text-danger" aria-hidden="true"></i>
</td>
@ -1210,6 +1261,9 @@
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="success">
<i class="fa fa-check text-success" aria-hidden="true"></i>
</td>
<td class="info">
<i class="fa fa-circle-o text-info" aria-hidden="true"></i>
</td>

108
docs/_providers/hedns.md Normal file
View File

@ -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 %}

View File

@ -78,6 +78,7 @@ Maintainers of contributed providers:
* `DNSIMPLE` @aeden
* `EXOSCALE` @pierre-emmanuelJ
* `GANDI_V5` @TomOnTime
* `HEDNS` @rblenkinsopp
* `HEXONET` @papakai
* `INTERNETBS` @pragmaton
* `INWX` @svenpeter42

5
go.mod
View File

@ -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

15
go.sum
View File

@ -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=

View File

@ -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, ".")),
),

View File

@ -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",

View File

@ -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"

View File

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