dnscontrol/providers/hetzner/types.go
Jakob Ackermann c073f2e654
HETZNER: gracefully handle FQDN labels when listing records (#3859)
- Closes https://github.com/StackExchange/dnscontrol/issues/3853

This PR is gracefully handling FQDN labels when listing records from the
Hetzner DNS Control api.

These records can be created via other tools or the browser UI.

Testing:

```diff
diff --git a/providers/hetzner/types.go b/providers/hetzner/types.go
index 964f1b7b..3429acc2 100644
--- a/providers/hetzner/types.go
+++ b/providers/hetzner/types.go
@@ -3,2 +3,3 @@ package hetzner
 import (
+       "fmt"
        "strings"
@@ -63,3 +64,3 @@ func fromRecordConfig(in *models.RecordConfig, zone zone) record {
        r := record{
-               Name:   in.GetLabel(),
+               Name:   in.GetLabelFQDN() + ".",
                Type:   in.Type,
@@ -69,2 +70,3 @@ func fromRecordConfig(in *models.RecordConfig, zone zone) record {
        }
+       fmt.Printf("CREATE: %q\n", r.Name)
 
@@ -93,2 +95,3 @@ func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) {
        }
+       fmt.Printf("LISTING: %q\n", r.Name)
        if strings.HasSuffix(r.Name, "."+domain+".") {
```

Config:
```js
var REG_NONE = NewRegistrar('none')
var DSP = NewDnsProvider("HETZNER")

D('testing1.dev', REG_NONE, DnsProvider(DSP),
  A('@', '127.0.0.1'),
  A('foo', '127.0.0.1')
)
```

First push:
```
Waiting for concurrent gathering(s) to complete...LISTING: "@"
LISTING: "@"
LISTING: "@"
LISTING: "@"
CREATE: "foo.testing1.dev."
DONE
******************** Domain: testing1.dev
1 correction (HETZNER)
#1: Batch creation of records:
	+ CREATE A foo.testing1.dev 127.0.0.1 ttl=300
SUCCESS!
Done. 1 corrections.
```

Second push (no-op):
```
Waiting for concurrent gathering(s) to complete...LISTING: "@"
LISTING: "@"
LISTING: "@"
LISTING: "@"
LISTING: "foo.testing1.dev."
DONE
******************** Domain: testing1.dev
Done. 0 corrections.
```

DNS query:
```
$ dig foo.testing1.dev. @helium.ns.hetzner.de.
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 53563
foo.testing1.dev.	300	IN	A	127.0.0.1
```

Additional testing:
- update/delete `foo`  when record `foo.testing1.dev.` exists, works
- creating `foo.testing1.dev` is treated as
`foo.testing1.dev.testing1.dev.` in the API, hence the specific suffix
check for `<dot>DOMAIN<dot>`
- Test with HETZNER_V2, rejects records with FQDN

```
FAILURE! has dot (.) suffix (invalid_input, 50f9cf872ed8f1f808fd33c25cf88a81)
```

<!--
## Before submiting a pull request

Please make sure you've run the following commands from the root
directory.

    bin/generate-all.sh

(this runs commands like "go generate", fixes formatting, and so on)

## Release changelog section

Help keep the release changelog clear by pre-naming the proper section
in the GitHub pull request title.

Some examples:
* CICD: Add required GHA permissions for goreleaser
* DOCS: Fixed providers with "contributor support" table
* ROUTE53: Allow R53_ALIAS records to enable target health evaluation

More examples/context can be found in the file .goreleaser.yml under the
'build' > 'changelog' key.
!-->
2025-12-01 09:08:43 -05:00

106 lines
2.7 KiB
Go

package hetzner
import (
"strings"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/txtutil"
)
type bulkCreateRecordsRequest struct {
Records []record `json:"records"`
}
type bulkUpdateRecordsRequest struct {
Records []record `json:"records"`
}
type createZoneRequest struct {
Name string `json:"name"`
}
type createZoneResponse struct {
Zone zone `json:"zone"`
}
type getAllRecordsResponse struct {
Records []record `json:"records"`
Meta struct {
Pagination struct {
LastPage int `json:"last_page"`
TotalEntries int `json:"total_entries"`
} `json:"pagination"`
} `json:"meta"`
}
type getAllZonesResponse struct {
Zones []zone `json:"zones"`
Meta struct {
Pagination struct {
LastPage int `json:"last_page"`
TotalEntries int `json:"total_entries"`
} `json:"pagination"`
} `json:"meta"`
}
type record struct {
ID string `json:"id"`
Name string `json:"name"`
TTL *uint32 `json:"ttl"`
Type string `json:"type"`
Value string `json:"value"`
ZoneID string `json:"zone_id"`
}
type zone struct {
ID string `json:"id"`
Name string `json:"name"`
NameServers []string `json:"ns"`
TTL uint32 `json:"ttl"`
}
func fromRecordConfig(in *models.RecordConfig, zone zone) record {
r := record{
Name: in.GetLabel(),
Type: in.Type,
Value: in.GetTargetCombinedFunc(txtutil.EncodeQuoted),
TTL: &in.TTL,
ZoneID: zone.ID,
}
if r.Type == "TXT" && (in.GetTargetTXTSegmentCount() == 1) {
// HACK: HETZNER rejects values that fit into 255 bytes w/o quotes,
// but do not fit w/ added quotes (via GetTargetCombined()).
// Sending the raw, non-quoted value works for the comprehensive
// suite of integrations tests.
// The HETZNER validation does not provide helpful error messages.
// {"error":{"message":"422 Unprocessable Entity: missing: ; ","code":422}}
// Last checked: 2023-04-01
valueNotQuoted := in.GetTargetTXTSegmented()[0]
if len(valueNotQuoted) == 254 || len(valueNotQuoted) == 255 {
r.Value = valueNotQuoted
}
}
return r
}
func toRecordConfig(domain string, r *record) (*models.RecordConfig, error) {
rc := models.RecordConfig{
Type: r.Type,
TTL: *r.TTL,
Original: r,
}
if strings.HasSuffix(r.Name, "."+domain+".") {
// Records created through other tools or the browser UI can contain FQDN labels.
rc.SetLabelFromFQDN(r.Name, domain)
} else {
rc.SetLabel(r.Name, domain)
}
// HACK: Hetzner is inserting a trailing space after multiple, quoted values.
// NOTE: The actual DNS answer does not contain the space.
// NOTE: The txtutil.ParseQuoted parser handles this just fine.
// Last checked: 2023-04-01
return &rc, rc.PopulateFromStringFunc(r.Type, r.Value, domain, txtutil.ParseQuoted)
}