Skip to content

Commit

Permalink
feat(provider): support Hetzner (#503)
Browse files Browse the repository at this point in the history
  • Loading branch information
lieblinger authored Nov 17, 2023
1 parent 496781a commit 301e4d6
Show file tree
Hide file tree
Showing 9 changed files with 434 additions and 0 deletions.
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

### 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 @@ -71,6 +72,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 @@ -115,6 +116,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"`
} `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)
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{
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) {
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

0 comments on commit 301e4d6

Please sign in to comment.