hostinger: fix record update (#2690)

This commit is contained in:
Ludovic Fernandez 2025-10-27 11:42:02 +01:00 committed by GitHub
commit 4bb17b0234
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 124 additions and 132 deletions

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-acme/lego/v4/challenge/dns01"
@ -103,38 +104,16 @@ func (d *DNSProvider) Present(domain, token, keyAuth string) error {
ctx := context.Background()
recordSets, err := d.client.GetDNSRecords(ctx, dns01.UnFqdn(authZone))
if err != nil {
return fmt.Errorf("hostinger: get DNS records: %w", err)
}
var newRecordSet []internal.RecordSet
var added bool
for _, recordSet := range recordSets {
if recordSet.Name == subDomain && recordSet.Type == "TXT" {
recordSet.Records = append(recordSet.Records, internal.Record{Content: info.Value})
added = true
}
newRecordSet = append(newRecordSet, recordSet)
}
if !added {
newRecordSet = append(newRecordSet, internal.RecordSet{
request := internal.ZoneRequest{
Overwrite: false,
Zone: []internal.RecordSet{{
Name: subDomain,
Type: "TXT",
TTL: d.config.TTL,
Records: []internal.Record{
{Content: info.Value},
},
})
}
request := internal.ZoneRequest{
Overwrite: false,
Zone: newRecordSet,
}},
}
err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request)
@ -161,45 +140,45 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
ctx := context.Background()
recordSets, err := d.client.GetDNSRecords(ctx, dns01.UnFqdn(authZone))
recordSet, err := d.findRecordSet(ctx, authZone, subDomain)
if err != nil {
return fmt.Errorf("hostinger: get DNS records: %w", err)
return fmt.Errorf("hostinger: %w", err)
}
var changed bool
var newRecords []internal.Record
var newRecordSet []internal.RecordSet
for _, recordSet := range recordSets {
if recordSet.Name == subDomain && recordSet.Type == "TXT" {
var rs []internal.Record
for _, record := range recordSet.Records {
if record.Content == info.Value {
changed = true
} else {
rs = append(rs, record)
}
}
recordSet.Records = rs
for _, record := range recordSet.Records {
if record.Content == info.Value || record.Content == strconv.Quote(info.Value) {
continue
}
newRecordSet = append(newRecordSet, recordSet)
newRecords = append(newRecords, record)
}
if !changed {
recordSet.Records = newRecords
if len(recordSet.Records) > 0 {
request := internal.ZoneRequest{
Overwrite: true,
Zone: []internal.RecordSet{recordSet},
}
err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request)
if err != nil {
return fmt.Errorf("hostinger: update DNS records (delete): %w", err)
}
return nil
}
request := internal.ZoneRequest{
Overwrite: false,
Zone: newRecordSet,
}
filters := []internal.Filter{{
Name: subDomain,
Type: "TXT",
}}
err = d.client.UpdateDNSRecords(ctx, dns01.UnFqdn(authZone), request)
err = d.client.DeleteDNSRecords(ctx, dns01.UnFqdn(authZone), filters)
if err != nil {
return fmt.Errorf("hostinger: update DNS records (delete): %w", err)
return fmt.Errorf("hostinger: delete DNS records: %w", err)
}
return nil
@ -210,3 +189,20 @@ func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findRecordSet(ctx context.Context, authZone, subDomain string) (internal.RecordSet, error) {
recordSets, err := d.client.GetDNSRecords(ctx, dns01.UnFqdn(authZone))
if err != nil {
return internal.RecordSet{}, fmt.Errorf("get DNS records: %w", err)
}
for _, recordSet := range recordSets {
if recordSet.Name != subDomain || recordSet.Type != "TXT" {
continue
}
return recordSet, nil
}
return internal.RecordSet{}, fmt.Errorf("no record found for domain %q and subdomain %q", authZone, subDomain)
}

View file

@ -1,7 +1,6 @@
package hostinger
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
@ -116,8 +115,6 @@ func mockBuilder() *servermock.Builder[*DNSProvider] {
func TestDNSProvider_Present(t *testing.T) {
provider := mockBuilder().
Route("GET /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("get_dns_records.json")).
Route("PUT /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("update_dns_records.json"),
servermock.CheckRequestJSONBodyFromInternal("update_dns_records-request.json")).
@ -127,20 +124,7 @@ func TestDNSProvider_Present(t *testing.T) {
require.NoError(t, err)
}
func TestDNSProvider_Present_empty(t *testing.T) {
provider := mockBuilder().
Route("GET /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("get_dns_records_empty.json")).
Route("PUT /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("update_dns_records.json"),
servermock.CheckRequestJSONBodyFromInternal("update_dns_records_empty-request.json")).
Build(t)
err := provider.Present("example.com", "", "123d==")
require.NoError(t, err)
}
func TestDNSProvider_CleanUp(t *testing.T) {
func TestDNSProvider_CleanUp_update(t *testing.T) {
provider := mockBuilder().
Route("GET /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("get_dns_records_acme.json")).
@ -153,12 +137,13 @@ func TestDNSProvider_CleanUp(t *testing.T) {
require.NoError(t, err)
}
func TestDNSProvider_CleanUp_empty(t *testing.T) {
func TestDNSProvider_CleanUp_delete(t *testing.T) {
provider := mockBuilder().
Route("GET /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("get_dns_records_empty.json")).
Route("PUT /api/dns/v1/zones/example.com",
servermock.Noop().WithStatusCode(http.StatusServiceUnavailable)).
Route("DELETE /api/dns/v1/zones/example.com",
servermock.ResponseFromInternal("delete_dns_records.json"),
servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)).
Build(t)
err := provider.CleanUp("example.com", "", "123d==")

View file

@ -73,6 +73,19 @@ func (c *Client) UpdateDNSRecords(ctx context.Context, domain string, zone ZoneR
return c.do(req, nil)
}
// DeleteDNSRecords deletes DNS records for the selected domain.
// https://developers.hostinger.com/#tag/dns-zone/delete/api/dns/v1/zones/{domain}
func (c *Client) DeleteDNSRecords(ctx context.Context, domain string, filters []Filter) error {
endpoint := c.BaseURL.JoinPath("/api/dns/v1/zones/", domain)
req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, Filters{Filters: filters})
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) do(req *http.Request, result any) error {
req.Header.Set(authorizationHeader, "Bearer "+c.token)

View file

@ -85,20 +85,11 @@ func TestClient_UpdateDNSRecords(t *testing.T) {
{
Name: "_acme-challenge",
Records: []Record{
{Content: "aaa"},
{Content: "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"},
},
TTL: 14400,
TTL: 120,
Type: "TXT",
},
{
Name: "_acme-challenge",
Records: []Record{{
Content: "example.com.",
}},
TTL: 14400,
Type: "A",
},
},
}
@ -128,3 +119,36 @@ func TestClient_UpdateDNSRecords_error(t *testing.T) {
require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: The name field is required. (and 1 more error): field_1: The field_1 field is required., The field_1 must be a number.")
}
func TestClient_DeleteDNSRecords(t *testing.T) {
client := mockBuilder().
Route("DELETE /api/dns/v1/zones/example.com",
servermock.ResponseFromFixture("delete_dns_records.json"),
servermock.CheckRequestJSONBody(`{"filters":[{"name":"_acme-challenge","type":"TXT"}]}`)).
Build(t)
filters := []Filter{{
Name: "_acme-challenge",
Type: "TXT",
}}
err := client.DeleteDNSRecords(t.Context(), "example.com", filters)
require.NoError(t, err)
}
func TestClient_DeleteDNSRecords_error(t *testing.T) {
client := mockBuilder().
Route("DELETE /api/dns/v1/zones/example.com",
servermock.ResponseFromFixture("error_401.json").
WithStatusCode(http.StatusUnauthorized)).
Build(t)
filters := []Filter{{
Name: "_acme-challenge",
Type: "TXT",
}}
err := client.DeleteDNSRecords(t.Context(), "example.com", filters)
require.EqualError(t, err, "26a91bd9-f8c8-4a83-9df9-83e23d696fe3: Unauthenticated")
}

View file

@ -0,0 +1,3 @@
{
"message": "Request accepted"
}

View file

@ -1,4 +1,14 @@
[
{
"name": "_acme-challenge",
"records": [
{
"content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
}
],
"ttl": 14400,
"type": "TXT"
},
{
"name": "_acme-challenge",
"records": [

View file

@ -4,25 +4,12 @@
{
"name": "_acme-challenge",
"records": [
{
"content": "aaa"
},
{
"content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
}
],
"ttl": 14400,
"ttl": 120,
"type": "TXT"
},
{
"name": "_acme-challenge",
"records": [
{
"content": "example.com."
}
],
"ttl": 14400,
"type": "A"
}
]
}

View file

@ -1,5 +1,5 @@
{
"overwrite": false,
"overwrite": true,
"zone": [
{
"name": "_acme-challenge",
@ -10,16 +10,6 @@
],
"ttl": 14400,
"type": "TXT"
},
{
"name": "_acme-challenge",
"records": [
{
"content": "example.com."
}
],
"ttl": 14400,
"type": "A"
}
]
}

View file

@ -1,25 +0,0 @@
{
"overwrite": false,
"zone": [
{
"name": "_acme-challenge",
"records": [
{
"content": "example.com."
}
],
"ttl": 14400,
"type": "A"
},
{
"name": "_acme-challenge",
"records": [
{
"content": "ADw2sEd82DUgXcQ9hNBZThJs7zVJkR5v9JeSbAb9mZY"
}
],
"ttl": 120,
"type": "TXT"
}
]
}

View file

@ -37,3 +37,12 @@ type Record struct {
Content string `json:"content,omitempty"`
IsDisabled bool `json:"is_disabled,omitempty"`
}
type Filters struct {
Filters []Filter `json:"filters"`
}
type Filter struct {
Name string `json:"name"`
Type string `json:"type"`
}