From 64c378528016953910ec327b32892634e0f2c004 Mon Sep 17 00:00:00 2001 From: WEBZaytsev Date: Sun, 24 May 2026 00:10:59 +0300 Subject: [PATCH] fix: migrate POST/PATCH to v2 API to fix DNS-01 ACME challenge --- client.go | 98 +++++++++++++++++++++++++++++++++++++++++++++---------- models.go | 76 +++++++++++++++++++++--------------------- 2 files changed, 118 insertions(+), 56 deletions(-) diff --git a/client.go b/client.go index 705c4cc..18d7ac1 100644 --- a/client.go +++ b/client.go @@ -7,36 +7,40 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/libdns/libdns" ) func (p *Provider) createRecord(ctx context.Context, zone string, record libdns.Record) (libdns.Record, error) { - body, err := json.Marshal(libdnsToPostRecord(record)) + fqdn := getFQDN(record, zone) + p.ensureSubdomain(ctx, zone, fqdn) + + body, err := json.Marshal(libdnsToRecord(record)) if err != nil { return libdns.RR{}, err } - reqURL := p.v1("domains/%s/dns-records", zone) + + reqURL := p.v2("domains/%s/dns-records", fqdn) req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body)) if err != nil { return libdns.RR{}, err } - var result SavedRecord + var result SavedRecordV2 err = p.doAPIRequest(req, &result) return result.libDNSRecord(record.RR().Name), err } func (p *Provider) updateRecord(ctx context.Context, zone string, record libdns.Record) (libdns.Record, error) { - body, err := json.Marshal(libdnsToPatchRecord(record)) + body, err := json.Marshal(libdnsToRecord(record)) if err != nil { return libdns.RR{}, err } recordID := getRecordID(record) if recordID == "" { - // Need to look up the record by name/type to get the ID existingRecords, err := p.GetRecords(ctx, zone) if err != nil { return libdns.RR{}, fmt.Errorf("failed to get records for update: %w", err) @@ -56,13 +60,13 @@ func (p *Provider) updateRecord(ctx context.Context, zone string, record libdns. } } - reqURL := p.v1("domains/%s/dns-records/%s", getFQDN(record, zone), recordID) + reqURL := p.v2("domains/%s/dns-records/%s", getFQDN(record, zone), recordID) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewReader(body)) if err != nil { return libdns.RR{}, err } - var result SavedRecord + var result SavedRecordV2 err = p.doAPIRequest(req, &result) return result.libDNSRecord(record.RR().Name), err @@ -71,7 +75,6 @@ func (p *Provider) updateRecord(ctx context.Context, zone string, record libdns. func (p *Provider) deleteRecord(ctx context.Context, zone string, record libdns.Record) error { recordID := getRecordID(record) if recordID == "" { - // Need to look up the record by name/type to get the ID existingRecords, err := p.GetRecords(ctx, zone) if err != nil { return fmt.Errorf("failed to get records for delete: %w", err) @@ -87,23 +90,81 @@ func (p *Provider) deleteRecord(ctx context.Context, zone string, record libdns. } if recordID == "" { - // Record doesn't exist, which is fine for delete return nil } } - reqURL := p.v2("domains/%s/dns-records/%s", getFQDN(record, zone), recordID) + fqdn := getFQDN(record, zone) + reqURL := p.v2("domains/%s/dns-records/%s", fqdn, recordID) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) if err != nil { return err } - err = p.doAPIRequest(req, nil) + if err = p.doAPIRequest(req, nil); err != nil { + return err + } - return err + p.cleanupSubdomain(ctx, zone, fqdn) + return nil } -func (p *Provider) doAPIRequest(req *http.Request, result interface{}) error { +// ensureSubdomain performs an idempotent create of the subdomain entity required +// by the v2 dns-records endpoint. It is fire-and-forget on purpose: +// - if the entity already exists, Timeweb returns an error and we ignore it; +// - if the create fails for any other reason, the subsequent v2 dns-records +// POST will surface a meaningful error to the caller. +// +// Note: despite the OpenAPI spec naming the path parameter "subdomain_fqdn", +// the v1 endpoint actually expects a relative subdomain name (e.g. "sub"). +// Passing a full FQDN causes Timeweb to append the zone again, producing +// a doubled name like "sub.zone.zone". +func (p *Provider) ensureSubdomain(ctx context.Context, zone, fqdn string) { + if fqdn == zone { + return + } + rel := strings.TrimSuffix(fqdn, "."+zone) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + p.v1("domains/%s/subdomains/%s", zone, rel), nil) + if err != nil { + return + } + p.doAPIRequest(req, nil) //nolint:errcheck +} + +// cleanupSubdomain removes the subdomain entity if no DNS records remain on it. +// This keeps the Timeweb control panel free of empty subdomains created during +// short-lived operations like ACME DNS-01 challenges, while preserving subdomains +// that still hold user-managed records. +// +// Cleanup is best-effort: if listing records or the delete itself fails the +// caller is not affected, because the primary record deletion has already +// succeeded by the time we get here. +func (p *Provider) cleanupSubdomain(ctx context.Context, zone, fqdn string) { + if fqdn == zone { + return + } + + remaining, err := p.GetRecords(ctx, zone) + if err != nil { + return + } + for _, r := range remaining { + if libdns.AbsoluteName(r.RR().Name, zone) == fqdn { + return + } + } + + rel := strings.TrimSuffix(fqdn, "."+zone) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, + p.v1("domains/%s/subdomains/%s", zone, rel), nil) + if err != nil { + return + } + p.doAPIRequest(req, nil) //nolint:errcheck +} + +func (p *Provider) doAPIRequest(req *http.Request, result any) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ApiToken)) @@ -111,19 +172,20 @@ func (p *Provider) doAPIRequest(req *http.Request, result interface{}) error { if err != nil { return err } - defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } if response.StatusCode >= 400 { return fmt.Errorf("got error status: HTTP %d: %+v", response.StatusCode, string(body)) } if response.StatusCode == http.StatusNoContent { - return err + return nil } - err = json.Unmarshal(body, result) - - return err + return json.Unmarshal(body, result) } diff --git a/models.go b/models.go index 8d19c19..a5ae813 100644 --- a/models.go +++ b/models.go @@ -8,6 +8,7 @@ import ( "github.com/libdns/libdns" ) +// RecordResponse is the DNS record structure returned by the v1 GET (user-records) endpoint. type RecordResponse struct { ID uint `json:"id"` Type string `json:"type"` @@ -19,10 +20,7 @@ type RecordResponse struct { TTL uint `json:"ttl"` } -type SavedRecord struct { - DNSRecord RecordResponse `json:"dns_record"` -} - +// RecordsResponse wraps the v1 GET list response. type RecordsResponse struct { Meta struct { Total int `json:"total"` @@ -42,12 +40,32 @@ type DomainsResponse struct { Domains []DomainResponse `json:"domains"` } -type TimewebRecord struct { - Subdomain string `json:"subdomain,omitempty"` - Type string `json:"type"` - TTL uint `json:"ttl,omitempty"` - Value string `json:"value"` - Priority uint `json:"priority,omitempty"` // MX priority +// RecordResponseV2 is the DNS record structure returned by the v2 POST/PATCH endpoints. +// Unlike v1, the subdomain field contains only the relative part (e.g. "sub", not "sub.example.com"). +type RecordResponseV2 struct { + ID uint `json:"id"` + Type string `json:"type"` + FQDN string `json:"fqdn"` + Data struct { + Value string `json:"value"` + Subdomain string `json:"subdomain"` + Priority uint `json:"priority,omitempty"` + } `json:"data"` + TTL uint `json:"ttl"` +} + +// SavedRecordV2 wraps the v2 POST/PATCH single-record response. +type SavedRecordV2 struct { + DNSRecord RecordResponseV2 `json:"dns_record"` +} + +// TimewebRecordV2 is the request body for v2 POST and PATCH. +// The target subdomain/FQDN is specified in the URL path, not the body. +type TimewebRecordV2 struct { + Type string `json:"type"` + TTL uint `json:"ttl,omitempty"` + Value string `json:"value,omitempty"` + Priority uint `json:"priority,omitempty"` } func buildLibDNSRecord(name, recordType, value string, priority, ttlSeconds, recordID uint) libdns.Record { @@ -56,17 +74,15 @@ func buildLibDNSRecord(name, recordType, value string, priority, ttlSeconds, rec name = "@" } - // Return typed records based on record type switch recordType { case "TXT": return libdns.TXT{ Name: name, Text: value, - ProviderData: recordID, // Store Timeweb ID for updates/deletes + ProviderData: recordID, TTL: ttl, } case "A", "AAAA": - // For A/AAAA records, we need to parse the IP address rr := libdns.RR{ Name: name, Type: recordType, @@ -75,7 +91,6 @@ func buildLibDNSRecord(name, recordType, value string, priority, ttlSeconds, rec } parsed, err := rr.Parse() if err == nil { - // Attach provider data to the parsed record if addr, ok := parsed.(libdns.Address); ok { addr.ProviderData = recordID return addr @@ -104,7 +119,6 @@ func buildLibDNSRecord(name, recordType, value string, priority, ttlSeconds, rec TTL: ttl, } default: - // For unknown types, return RR return libdns.RR{ Name: name, Type: recordType, @@ -114,7 +128,7 @@ func buildLibDNSRecord(name, recordType, value string, priority, ttlSeconds, rec } } -func (r *SavedRecord) libDNSRecord(name string) libdns.Record { +func (r *SavedRecordV2) libDNSRecord(name string) libdns.Record { return buildLibDNSRecord( name, r.DNSRecord.Type, @@ -136,42 +150,28 @@ func (r *RecordResponse) libDNSRecord() libdns.Record { ) } -func libdnsToPostRecord(r libdns.Record) TimewebRecord { - return libdnsToRecord(r, true) -} - -func libdnsToPatchRecord(r libdns.Record) TimewebRecord { - return libdnsToRecord(r, false) -} - -func libdnsToRecord(r libdns.Record, haveSubdomain bool) TimewebRecord { +// libdnsToRecord converts a libdns record to a Timeweb v2 API request body. +// The target subdomain is specified separately in the URL path via getFQDN. +func libdnsToRecord(r libdns.Record) TimewebRecordV2 { rr := r.RR() - name := rr.Name - if !haveSubdomain || name == "@" { - name = "" - } - rec := TimewebRecord{ - Type: rr.Type, - Value: rr.Data, - TTL: uint(rr.TTL.Seconds()), - Subdomain: name, + rec := TimewebRecordV2{ + Type: rr.Type, + Value: rr.Data, + TTL: uint(rr.TTL.Seconds()), } - // Populate structured fields for known complex types switch v := r.(type) { case libdns.CNAME: rec.Value = strings.TrimSuffix(v.Target, ".") case libdns.MX: rec.Value = strings.TrimSuffix(v.Target, ".") rec.Priority = uint(v.Preference) - default: - // do nothing } return rec } -// getRecordID extracts the Timeweb record ID from ProviderData or returns empty string +// getRecordID extracts the Timeweb record ID from ProviderData or returns empty string. func getRecordID(r libdns.Record) string { switch rec := r.(type) { case libdns.TXT: