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
98 changes: 80 additions & 18 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -87,43 +90,102 @@ 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))

response, err := http.DefaultClient.Do(req)
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)
}
76 changes: 38 additions & 38 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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:
Expand Down