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

This commit is contained in:
Robert Blenkinsopp 2020-08-22 16:11:16 +01:00
parent fc33c1dfdd
commit ad96d44ab1
2 changed files with 132 additions and 22 deletions

View file

@ -6,10 +6,7 @@ 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:
In your `creds.json` file you must provide your `dns.he.net` account username and password:
{% highlight json %}
{
@ -60,6 +57,35 @@ only available when first enabling two-factor authentication.
}
{% 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 location where a `.hedns-session` file will be created. This can be used to allow reuse of an
existing session, between runs, without full authentication.
When this key is not present, this option is disabled by default.
**Important Notes**:
* 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 your user 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.

View file

@ -1,11 +1,15 @@
package hedns
import (
"crypto/sha1"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path"
"sort"
"strconv"
"strings"
@ -70,14 +74,16 @@ const (
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."
SessionFileName = ".hedns-session"
)
type ApiClient struct {
Username string
Password string
TfaSecret string
TfaValue string
Username string
Password string
TfaSecret string
TfaValue string
SessionFilePath string
httpClient http.Client
}
@ -92,6 +98,7 @@ type Record struct {
func NewProvider(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")
@ -103,17 +110,27 @@ func NewProvider(cfg map[string]string, _ json.RawMessage) (providers.DNSService
return nil, fmt.Errorf("totp and totp-key must not be specified at the same time")
}
if totpValue == "" && totpSecret != "" {
var err error
totpValue, err = totp.GenerateCode(totpSecret, time.Now())
if err != nil {
return nil, err
}
}
// Perform the initial login
client := NewApiClient(username, password, totpSecret)
client := NewApiClient(username, password, totpSecret, totpValue, sessionFilePath)
err := client.authenticate()
return client, err
}
func NewApiClient(username, password, tfaSecret string) *ApiClient {
func NewApiClient(username, password, tfaSecret string, tfaValue string, sessionFilePath string) *ApiClient {
client := ApiClient{
Username: username,
Password: password,
TfaSecret: tfaSecret,
Username: username,
Password: password,
TfaSecret: tfaSecret,
TfaValue: tfaValue,
SessionFilePath: sessionFilePath,
}
// Create storage for the cookies
@ -377,14 +394,6 @@ func (c *ApiClient) authUsernameAndPassword() (authenticated bool, requiresTfa b
}
func (c *ApiClient) auth2FA() (authenticated bool, err error) {
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(ApiUrl, url.Values{
"tfacode": {c.TfaValue},
"submit": {"Submit"},
@ -410,6 +419,11 @@ func (c *ApiClient) auth2FA() (authenticated bool, err error) {
}
func (c *ApiClient) authenticate() error {
if c.SessionFilePath != "" {
_ = c.loadSessionFile()
}
authenticated, requiresTfa, err := c.authResumeSession()
if err != nil {
return err
@ -435,6 +449,10 @@ func (c *ApiClient) authenticate() error {
if !authenticated {
err = fmt.Errorf("unknown authentication failure")
} else {
if c.SessionFilePath != "" {
err = c.saveSessionFile()
}
}
return err
@ -587,6 +605,72 @@ func (c *ApiClient) setZoneDynamicKey(rc *models.RecordConfig, key string) error
return err
}
func (c *ApiClient) generateCredentialHash() string {
hash := sha1.New()
hash.Write([]byte(c.Username))
hash.Write([]byte(c.Password))
//hash.Write([]byte(c.TfaValue))
hash.Write([]byte(c.TfaSecret))
return fmt.Sprintf("%x", hash.Sum(nil))
}
func (c *ApiClient) saveSessionFile() error {
cookieDomain, err := url.Parse(ApiUrl)
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 *ApiClient) loadSessionFile() error {
cookieDomain, err := url.Parse(ApiUrl)
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 *ApiClient) parseResponseForDocumentAndErrors(response *http.Response) (document *goquery.Document, err error) {
var ignoredErrorMessages = [...]string{
ErrorImproperDelegation,