Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions docs/hetznernetworking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Hetzner Networking

## Configuration

### Example

```json
{
"settings": [
{
"provider": "hetznernetworking",
"zone_identifier": "example.com",
"domain": "example.com",
"ttl": 600,
"token": "yourtoken",
"ip_version": "ipv4",
"ipv6_suffix": ""
}
]
}
```

### Compulsory parameters

- `"zone_identifier"` is the DNS zone name (e.g., `example.com`), not a zone ID
- `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
- `"ttl"` optional integer value corresponding to a number of seconds
- One of the following ([how to find API keys](https://docs.hetzner.cloud/api/getting-started/generating-api-token)):
- API Token `"token"`, configured with DNS write permissions for your DNS zone

### Optional parameters

- `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public ip found). It defaults to `ipv4 or ipv6`.
- `"ipv6_suffix"` is the IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in the record updating.

## Notes

This provider uses the Hetzner Networking DNS API (https://api.hetzner.cloud/v1/) which is different from the legacy Hetzner DNS API.

- The `zone_identifier` should be the DNS zone name (e.g., `example.com`), not a zone ID
- For subdomains, the provider automatically extracts the subdomain part relative to the zone
- For apex records (root domain), the provider uses `@` as the record name
- The API uses RRSet-based operations for managing DNS records

For more information about the Hetzner Networking DNS API, see the [official documentation](https://docs.hetzner.cloud/reference/cloud#dns).
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
GoIP models.Provider = "goip"
HE models.Provider = "he"
Hetzner models.Provider = "hetzner"
HetznerNetworking models.Provider = "hetznernetworking"
Infomaniak models.Provider = "infomaniak"
INWX models.Provider = "inwx"
Ionos models.Provider = "ionos"
Expand Down Expand Up @@ -86,6 +87,7 @@ func ProviderChoices() []models.Provider {
GoIP,
HE,
Hetzner,
HetznerNetworking,
Infomaniak,
INWX,
Ionos,
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/goip"
"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/hetznernetworking"
"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/ionos"
Expand Down Expand Up @@ -138,6 +139,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return he.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Hetzner:
return hetzner.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.HetznerNetworking:
return hetznernetworking.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Infomaniak:
return infomaniak.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.INWX:
Expand Down
145 changes: 145 additions & 0 deletions internal/provider/providers/hetznercloud/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package hetznercloud

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
owner string
ipVersion ipversion.IPVersion
ipv6Suffix netip.Prefix
token string
zoneIdentifier string
ttl uint32
}

func New(data json.RawMessage, domain, owner string,
ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
p *Provider, err error,
) {
extraSettings := struct {
Token string `json:"token"`
ZoneIdentifier string `json:"zone_identifier"`
TTL uint32 `json:"ttl"`
}{}
err = json.Unmarshal(data, &extraSettings)
if err != nil {
return nil, err
}

ttl := uint32(1)
if extraSettings.TTL > 0 {
ttl = extraSettings.TTL
}

err = validateSettings(domain, extraSettings.ZoneIdentifier, extraSettings.Token)
if err != nil {
return nil, fmt.Errorf("validating provider specific settings: %w", err)
}

return &Provider{
domain: domain,
owner: owner,
ipVersion: ipVersion,
ipv6Suffix: ipv6Suffix,
token: extraSettings.Token,
zoneIdentifier: extraSettings.ZoneIdentifier,
ttl: ttl,
}, nil
}

func validateSettings(domain, zoneIdentifier, token string) (err error) {
err = utils.CheckDomain(domain)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
}

switch {
case zoneIdentifier == "":
return fmt.Errorf("%w", errors.ErrZoneIdentifierNotSet)
case token == "":
return fmt.Errorf("%w", errors.ErrTokenNotSet)
}
return nil
}

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

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

func (p *Provider) Owner() string {
return p.owner
}

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

func (p *Provider) IPv6Suffix() netip.Prefix {
return p.ipv6Suffix
}

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

func (p *Provider) BuildDomainName() string {
// Override to preserve wildcard characters for Hetzner Cloud API
if p.owner == "@" {
return p.domain
}
// Don't replace * with "any" for wildcard domains
return p.owner + "." + p.domain
}

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

// Update updates the DNS record with the given IP address.
// It first checks if a record exists and if the IP is up to date.
// If the record doesn't exist, it creates a new one.
// If the record exists but has a different IP, it updates the record.
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
}
14 changes: 14 additions & 0 deletions internal/provider/providers/hetznernetworking/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package hetznernetworking

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("Authorization", "Bearer "+p.token)
}
83 changes: 83 additions & 0 deletions internal/provider/providers/hetznernetworking/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package hetznernetworking

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

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

// createRecord creates a new DNS record using the add_records action.
// It adds the new IP address to the existing RRSet or creates a new RRSet.
func (p *Provider) createRecord(ctx context.Context, client *http.Client, ip netip.Addr) (err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}

// Extract RR name from domain relative to zone
rrName, err := p.extractRRName()
if err != nil {
return fmt.Errorf("extracting RR name: %w", err)
}

urlString := fmt.Sprintf("https://api.hetzner.cloud/v1/zones/%s/rrsets/%s/%s/actions/add_records", p.zoneIdentifier, rrName, recordType)

requestData := recordsRequest{
TTL: p.ttl,
Records: []recordValue{
{Value: ip.String()},
},
}

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, urlString, 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.StatusCreated {
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode,
utils.BodyToSingleLine(response.Body))
}

decoder := json.NewDecoder(response.Body)
var actionResp actionResponse
err = decoder.Decode(&actionResp)
if err != nil {
return fmt.Errorf("json decoding response body: %w", err)
}

// Verify the action was created successfully
if actionResp.Action.ID == 0 {
return fmt.Errorf("%w", errors.ErrReceivedNoResult)
}

// Check if action status indicates success or is still running
if actionResp.Action.Status != "running" && actionResp.Action.Status != "success" {
return fmt.Errorf("%w: action status %s", errors.ErrUnsuccessful, actionResp.Action.Status)
}

return nil
}
Loading