Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(provider): add suport for Hetzner #503

Merged
merged 29 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
81accea
[ADD] Hetzner: Initial
lieblinger Jul 12, 2023
e876c4d
[ADD] Hetzner: Bugfixes and improvements
lieblinger Jul 14, 2023
61cd683
[IMP] hetzner: Set Auth-API-Token in provider.go
lieblinger Jul 29, 2023
5534ad4
[IMP] hetzner: Remove uneccessary comment
lieblinger Jul 29, 2023
a655f35
[IMP] hetzner: Convert switch to regular if-statement
lieblinger Jul 29, 2023
87eb753
[IMP] hetzner: Convert switch to regular if-statement
lieblinger Jul 29, 2023
f5e0b55
[IMP] hetzner: Use netip.Addr instead of string on listRecordsResponse
lieblinger Jul 29, 2023
307685f
[IMP] hetzner: remove empty line
lieblinger Jul 29, 2023
8f1d2c1
[IMP] hetzner: Remove comments
lieblinger Jul 29, 2023
87c3e22
[IMP] hetzner: Add check for empty response without id
lieblinger Jul 29, 2023
570ee44
[IMP] hetzner: Remove comments - 2
lieblinger Jul 29, 2023
c1f3a57
[IMP] hetzner: Set default ttl to 1
lieblinger Jul 29, 2023
da9642f
Merge branch 'master' of https://github.com/lieblinger/ddns-updater
lieblinger Jul 29, 2023
edfcc92
[RM] hetzner: Remove key cuase it's not needed
lieblinger Jul 29, 2023
6276a93
[IMP] hetzner: Refactor getRecord function
lieblinger Jul 29, 2023
7a78a25
[IMP] hetzner: Unexport Create function
lieblinger Jul 29, 2023
eb8b21c
Merge branch 'master' of https://github.com/lieblinger/ddns-updater
lieblinger Jul 29, 2023
8a2b0b2
[IMP] Remove unneeded comment
lieblinger Aug 12, 2023
a820b2f
[IMP] hetzner: Refactor
lieblinger Aug 14, 2023
168e864
[IMP] Hetzner: Path variable
lieblinger Oct 10, 2023
14eb5a0
[IMP] Hetzner: IP compare
lieblinger Oct 10, 2023
aaf0470
[IMP] Hetzner: Error handling
lieblinger Oct 10, 2023
5ea769d
[IMP] Hetzner: Always set token
lieblinger Oct 10, 2023
2deef71
[IMP] Hetzner: Rename function variable newip -> ip
lieblinger Oct 10, 2023
acc4199
[IMP] Hetzner: Better response statuscode handling
lieblinger Oct 10, 2023
f3ccf8d
[IMP] Hetzner: Validation for provider
lieblinger Oct 10, 2023
4c7bb5d
[IMP] Hetzner: JSON struct
lieblinger Oct 10, 2023
17a7345
[IMP] Hetzner: Compare ip on create
lieblinger Oct 10, 2023
6229f69
[IMP] Hetzner: Handle StatusNotFound on getrecord
lieblinger Oct 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Light container updating DNS A and/or AAAA records periodically for multiple DNS
- GoDaddy
- Google
- He.net
- Hetzner
- Infomaniak
- INWX
- Linode
Expand Down
34 changes: 34 additions & 0 deletions docs/hetzner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Hetzner

## Configuration

### Example

```json
{
"settings": [
{
"provider": "hetzner",
"zone_identifier": "some id",
"domain": "domain.com",
"host": "@",
"ttl": 600,
"token": "yourtoken",
"ip_version": "ipv4"
}
]
}
```

### Compulsory parameters

- `"zone_identifier"` is the Zone ID of your site, from the domain overview page written as *Zone ID*
- `"domain"`
- `"host"` is your host and can be `"@"`, a subdomain or the wildcard `"*"`.
- `"ttl"` optional integer value corresponding to a number of seconds
- One of the following ([how to find API keys](https://docs.hetzner.com/cloud/api/getting-started/generating-api-token)):
- API Token `"token"`, configured with DNS edit permissions for your DNS name's zone
lieblinger marked this conversation as resolved.
Show resolved Hide resolved

### Optional parameters

- `"ip_version"` can be `ipv4` (A records) or `ipv6` (AAAA records), and defaults to `ipv4 or ipv6`
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
GoDaddy models.Provider = "godaddy"
Google models.Provider = "google"
HE models.Provider = "he"
Hetzner models.Provider = "hetzner"
Infomaniak models.Provider = "infomaniak"
INWX models.Provider = "inwx"
Linode models.Provider = "linode"
Expand Down Expand Up @@ -70,6 +71,7 @@ func ProviderChoices() []models.Provider {
GoDaddy,
Google,
HE,
Hetzner,
Infomaniak,
INWX,
Linode,
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/godaddy"
"github.com/qdm12/ddns-updater/internal/provider/providers/google"
"github.com/qdm12/ddns-updater/internal/provider/providers/he"
"github.com/qdm12/ddns-updater/internal/provider/providers/hetzner"
"github.com/qdm12/ddns-updater/internal/provider/providers/infomaniak"
"github.com/qdm12/ddns-updater/internal/provider/providers/inwx"
"github.com/qdm12/ddns-updater/internal/provider/providers/linode"
Expand Down Expand Up @@ -114,6 +115,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, host string
return google.New(data, domain, host, ipVersion)
case constants.HE:
return he.New(data, domain, host, ipVersion)
case constants.Hetzner:
return hetzner.New(data, domain, host, ipVersion)
case constants.Infomaniak:
return infomaniak.New(data, domain, host, ipVersion)
case constants.INWX:
Expand Down
14 changes: 14 additions & 0 deletions internal/provider/providers/hetzner/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package hetzner

import (
"net/http"

"github.com/qdm12/ddns-updater/internal/provider/headers"
)

func (p *Provider) setHeaders(request *http.Request) {
headers.SetUserAgent(request)
headers.SetContentType(request, "application/json")
headers.SetAccept(request, "application/json")
request.Header.Set("Auth-API-Token", p.token)
}
90 changes: 90 additions & 0 deletions internal/provider/providers/hetzner/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package hetzner

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"

"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
)

func (p *Provider) createRecord(ctx context.Context, client *http.Client, ip netip.Addr) (err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}

u := url.URL{
Scheme: "https",
Host: "dns.hetzner.com",
Path: "/api/v1/records",
}

requestData := struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
ZoneIdentifier string `json:"zone_id"`
TTL uint `json:"ttl"`
}{
Type: recordType,
Name: p.host,
Value: ip.String(),
ZoneIdentifier: p.zoneIdentifier,
TTL: p.ttl,
}

buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
err = encoder.Encode(requestData)
if err != nil {
return fmt.Errorf("JSON encoding request data: %w", err)
}

request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
if err != nil {
return fmt.Errorf("creating http request: %w", err)
}

p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()

if response.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode,
utils.BodyToSingleLine(response.Body))
}

decoder := json.NewDecoder(response.Body)
var parsedJSON struct {
Record struct {
ID string `json:"id"`
Value netip.Addr `json:"value"`
lieblinger marked this conversation as resolved.
Show resolved Hide resolved
} `json:"record"`
}
err = decoder.Decode(&parsedJSON)
newIP := parsedJSON.Record.Value
if err != nil {
return fmt.Errorf("json decoding response body: %w", err)
} else if newIP.Compare(ip) != 0 {
return fmt.Errorf("%w: sent ip %s to update but received %s",
errors.ErrIPReceivedMismatch, ip, newIP)
}

if parsedJSON.Record.ID == "" {
return fmt.Errorf("%w", errors.ErrReceivedNoResult)
}

return nil
}
81 changes: 81 additions & 0 deletions internal/provider/providers/hetzner/getrecord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package hetzner

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/netip"
"net/url"

"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
)

// See https://dns.hetzner.com/api-docs#operation/GetZones.
func (p *Provider) getRecordID(ctx context.Context, client *http.Client, ip netip.Addr) (
identifier string, upToDate bool, err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}

u := url.URL{
Scheme: "https",
Host: "dns.hetzner.com",
Path: "/api/v1/records",
}

values := url.Values{}
values.Set("zone_id", p.zoneIdentifier)
values.Set("name", p.host)
values.Set("type", recordType)
values.Set("page", "1")
values.Set("per_page", "1")
u.RawQuery = values.Encode()

request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", false, fmt.Errorf("creating http request: %w", err)
}
p.setHeaders(request)

response, err := client.Do(request)
if err != nil {
return "", false, err
}
defer response.Body.Close()

switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
return "", false, fmt.Errorf("%w", errors.ErrReceivedNoResult)
default:
return "", false, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.BodyToSingleLine(response.Body))
}

decoder := json.NewDecoder(response.Body)
listRecordsResponse := struct {
Records []struct {
ID string `json:"id"`
Value netip.Addr `json:"value"`
} `json:"records"`
}{}
err = decoder.Decode(&listRecordsResponse)
if err != nil {
return "", false, fmt.Errorf("json decoding response body: %w", err)
}

switch {
case len(listRecordsResponse.Records) == 0:
return "", false, fmt.Errorf("%w", errors.ErrReceivedNoResult)
lieblinger marked this conversation as resolved.
Show resolved Hide resolved
case len(listRecordsResponse.Records) > 1:
return "", false, fmt.Errorf("%w: %d instead of 1",
errors.ErrResultsCountReceived, len(listRecordsResponse.Records))
}
identifier = listRecordsResponse.Records[0].ID
upToDate = listRecordsResponse.Records[0].Value.Compare(ip) == 0
return identifier, upToDate, nil
}
120 changes: 120 additions & 0 deletions internal/provider/providers/hetzner/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package hetzner

import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/netip"

"github.com/qdm12/ddns-updater/internal/models"
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)

type Provider struct {
domain string
host string
ipVersion ipversion.IPVersion
token string
zoneIdentifier string
ttl uint
}

func New(data json.RawMessage, domain, host string,
ipVersion ipversion.IPVersion) (p *Provider, err error) {
extraSettings := struct {
Token string `json:"token"`
ZoneIdentifier string `json:"zone_identifier"`
TTL uint `json:"ttl"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, err
}
p = &Provider{
lieblinger marked this conversation as resolved.
Show resolved Hide resolved
domain: domain,
host: host,
ipVersion: ipVersion,
token: extraSettings.Token,
zoneIdentifier: extraSettings.ZoneIdentifier,
ttl: extraSettings.TTL,
}
if p.ttl == 0 {
p.ttl = 1
}
err = p.isValid()
if err != nil {
return nil, err
}
return p, nil
}

func (p *Provider) isValid() error {
switch {
case p.zoneIdentifier == "":
return fmt.Errorf("%w", errors.ErrZoneIdentifierNotSet)
case p.token == "":
return fmt.Errorf("%w", errors.ErrTokenNotSet)
}
return nil
}

func (p *Provider) String() string {
return utils.ToString(p.domain, p.host, constants.Hetzner, p.ipVersion)
}

func (p *Provider) Domain() string {
return p.domain
}

func (p *Provider) Host() string {
return p.host
}

func (p *Provider) IPVersion() ipversion.IPVersion {
return p.ipVersion
}

func (p *Provider) Proxied() bool {
return false
}

func (p *Provider) BuildDomainName() string {
return utils.BuildDomainName(p.host, p.domain)
}

func (p *Provider) HTML() models.HTMLRow {
return models.HTMLRow{
Domain: fmt.Sprintf("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Host: p.Host(),
Provider: "<a href=\"https://www.hetzner.com\">Hetzner</a>",
IPVersion: p.ipVersion.String(),
}
}

func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
lieblinger marked this conversation as resolved.
Show resolved Hide resolved
recordID, upToDate, err := p.getRecordID(ctx, client, ip)
switch {
case stderrors.Is(err, errors.ErrReceivedNoResult):
err = p.createRecord(ctx, client, ip)
if err != nil {
return netip.Addr{}, fmt.Errorf("creating record: %w", err)
}
return ip, nil
case err != nil:
return netip.Addr{}, fmt.Errorf("getting record id: %w", err)
case upToDate:
return ip, nil
}

ip, err = p.updateRecord(ctx, client, recordID, ip)
if err != nil {
return newIP, fmt.Errorf("updating record: %w", err)
}

return ip, nil
}
Loading