Skip to content

Conversation

@likt0r
Copy link

@likt0r likt0r commented Oct 26, 2025

Add Hetzner Networking DNS Provider

Overview

Adds support for the new Hetzner Cloud DNS API with the hetznernetworking provider. This is a separate provider from the existing hetzner provider, offering modern RRSet-based operations and wildcard domain support.

Features

  • Wildcard domain support (*.example.com)
  • Bearer token authentication
  • RRSet-based API operations
  • IPv4/IPv6 support
  • Configurable TTL

Configuration

{
  "settings": [
    {
      "provider": "hetznernetworking",
      "zone_identifier": "example.com",
      "domain": "sub.example.com",
      "token": "your-api-token",
      "ttl": 600,
      "ip_version": "ipv4"
    }
  ]
}

API Endpoints

  • GET /v1/zones/{zone}/rrsets/{name}/{type} - Get records
  • POST /v1/zones/{zone}/rrsets/{name}/{type}/actions/add_records - Create records
  • POST /v1/zones/{zone}/rrsets/{name}/{type}/actions/set_records - Update records

Testing

  • Compiles without errors
  • Wildcard domains working
  • API integration verified

Breaking Changes

None - purely additive change. Legacy hetzner provider remains unchanged.

Documentation

@JarodSch
Copy link

Can someone merge this?

@NiklasReisser
Copy link

I'd be interested in this as well.

Out of curiosity (I don't know much about Go):

  • What is the hetznercloud provider used for? It seems incomplete.
  • When trying to build the docker container, I get a few linter errors (after removing hetznercloud, which gives other errors):
internal/provider/providers/hetznernetworking/create.go:30: the line is 137 characters long, which exceeds the maximum of 120 characters. (lll)
	urlString := fmt.Sprintf("https://api.hetzner.cloud/v1/zones/%s/rrsets/%s/%s/actions/add_records", p.zoneIdentifier, rrName, recordType)
internal/provider/providers/hetznernetworking/update.go:32: the line is 137 characters long, which exceeds the maximum of 120 characters. (lll)
	urlString := fmt.Sprintf("https://api.hetzner.cloud/v1/zones/%s/rrsets/%s/%s/actions/set_records", p.zoneIdentifier, rrName, recordType)
internal/provider/providers/hetznernetworking/update.go:19:2: unused-parameter: parameter 'recordID' seems to be unused, consider removing or renaming it as _ (revive)
	recordID string, ip netip.Addr,
	^
internal/provider/providers/hetznernetworking/getrecord.go:102:14: do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"domain %s is not a subdomain of zone %s\", domain, zone)" (err113)
		return "", fmt.Errorf("domain %s is not a subdomain of zone %s", domain, zone)
		           ^
The command '/bin/sh -c golangci-lint run --timeout=10m' returned a non-zero code: 1

Am I doing something wrong?

@suntorytimed
Copy link

I have built the container and pushed it as suntorytimed/ddns-updater:hetzner-cloud-dns to Docker Hub and replaced the container and provider information on my TrueNAS homeserver. Works flawlessly. Thank you @likt0r for your work. @qdm12 would be nice to get this merged and pushed officially.

@NiklasReisser
Copy link

@suntorytimed How did you solve the issues I described? If you didn't see those, how did you build the container? Thanks in advance!

@RogerSik
Copy link

Does this work fine with wildcard? Because with this image every 5 minutes ddns-updater says it updated the wildcard dns entry but there was no change. The normal dns entry dont show this behavour.

  config.json: |
    {
      "settings": [
        {
          "provider": "hetznernetworking",
          "zone_identifier": "example.org",
          "domain": "example.org",
          "ttl": 300,
          "token": "REDACTED",
          "ip_version": "ipv4"
        },
        {
          "provider": "hetznernetworking",
          "zone_identifier": "example.org",
          "domain": "*.example.org",
          "ttl": 300,
          "token": "REDACTED",
          "ip_version": "ipv4"
        }
    }

@qdm12
Copy link
Owner

qdm12 commented Jan 17, 2026

Sorry for the massive delay everyone, life got in the way;
I'm happy to replace hetzner with this implementation, is there any reason to have it as a separate implementation?

Comment on lines +102 to +103
func (p *Provider) BuildDomainName() string {
// Override to preserve wildcard characters for Hetzner Cloud API
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that will cause issues; BuildDomainName() is used in a few places to resolve the domain name and compare it with the current public ip address. *.domain.com is not something that can be resolved, unlike any.domain.com

Comment on lines +12 to +13
"zone_identifier": "example.com",
"domain": "example.com",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we extract zone_identifier from domain instead? i.e. x.y.com -> y.com - it looks like extractRRName does it?

Comment on lines +28 to +29
- 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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- 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
- `"token"` is your API token configured with DNS write permissions for your DNS zone, see [how to find API keys](https://docs.hetzner.cloud/api/getting-started/generating-api-token)

return nil, err
}

ttl := uint32(1)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this ok for the default ttl? Shouldn't it be i.e. 5 minutes?

}

// Check if action status indicates success or is still running
if actionResp.Action.Status != "running" && actionResp.Action.Status != "success" {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the action is still running, we might want to run this in a loop with a fixed amount of retries and sleeps between each request, to make sure it completes successfully

for example if the hetzner api is malfunctioning, and stalls, we would indicate as "success" although it would not update the record.

}

// rrSetResponse represents the response from Hetzner Networking API RRSet GET requests
type rrSetResponse struct {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit since this is used only in getRecordID, let's inline the type definition in there. For example

var rrSetResponse struct {
	RRSet struct {
		ID      string `json:"id"`
		Records []struct {
			Value string `json:"value"`
		} `json:"records"`
	} `json:"rrset"`
}

and remove it from types.go

recordType = constants.AAAA
}

// Extract RR name from domain relative to zone
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit unneeded comment

Suggested change
// Extract RR name from domain relative to zone

// For example: domain="example.com", zone="example.com" -> "@"
// For example: domain="*.sub.example.com", zone="example.com" -> "*.sub"
func (p *Provider) extractRRName() (string, error) {
domain := p.BuildDomainName()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest you use https://pkg.go.dev/golang.org/x/net/publicsuffix#EffectiveTLDPlusOne instead. A unit test for this function wouldn't hurt either 😉

This is how the owners (rr names) are extracted from the domain string in the settings parsing:

newDomainRegistered, err := publicsuffix.EffectiveTLDPlusOne(domain)

@@ -0,0 +1,145 @@
package hetznercloud
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove, not sure why this is here

headers.SetUserAgent(request)
headers.SetContentType(request, "application/json")
headers.SetAccept(request, "application/json")
request.Header.Set("Authorization", "Bearer "+p.token)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
request.Header.Set("Authorization", "Bearer "+p.token)
headers.SetAuthBearer(p.token)

@burned42
Copy link

is there any reason to have it as a separate implementation?

DNS management was previously in a separate DNS console and got now added to the Hetzner console (both having their own API). At the moment there's a timeframe where both still exist to allow a smooth transition.

So at the moment both the old and new API exist but you can only have your DNS zone in either the old or the current panel and you can also only use the respective API for the old or current panel.

That means supporting both in ddns-updater until the old panel and API get shut down will allow users to choose when to migrate their zones. If the current implementation would just be replaced, users would be forced to migrate their zones to the new panel as soon as they update ddns-updater.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants