package hetzner import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" pvd "git.cloonar.com/cloonar/updns/internal/provider" ) const defaultAPIBase = "https://dns.hetzner.com" type provider struct { token string client *http.Client apiBaseURL string } type zone struct { ID string `json:"id"` Name string `json:"name"` } type zonesResponse struct { Zones []zone `json:"zones"` } type record struct { ID string `json:"id"` Name string `json:"name"` Value string `json:"value"` Type string `json:"type"` } type recordsResponse struct { Records []record `json:"records"` } // NewProvider creates a Hetzner DNS provider using the official API. func NewProvider(token string) pvd.Provider { return &provider{token: token, client: http.DefaultClient, apiBaseURL: defaultAPIBase} } // NewProviderWithURL creates a Hetzner provider with a custom API base URL (for testing). func NewProviderWithURL(token, apiBaseURL string) pvd.Provider { return &provider{token: token, client: http.DefaultClient, apiBaseURL: apiBaseURL} } // UpdateRecord updates the DNS record for the given domain to the provided IP. func (p *provider) UpdateRecord(ctx context.Context, domain, ip string) error { // Determine zone name (last two labels, or single label for two-part domains) parts := strings.Split(domain, ".") if len(parts) < 2 { return fmt.Errorf("invalid domain: %s", domain) } var zoneName string if len(parts) == 2 { zoneName = parts[1] } else { zoneName = strings.Join(parts[len(parts)-2:], ".") } // Fetch zone ID zonesURL := fmt.Sprintf("%s/zones?name=%s", p.apiBaseURL, zoneName) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, zonesURL, nil) req.Header.Set("Authorization", "Bearer "+p.token) resp, err := p.client.Do(req) if err != nil { return fmt.Errorf("fetch zones: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("hetzner update failed with status: %s", resp.Status) } var zr zonesResponse if err := json.NewDecoder(resp.Body).Decode(&zr); err != nil { return fmt.Errorf("parsing zones response: %w", err) } if len(zr.Zones) == 0 { return fmt.Errorf("zone %s not found", zoneName) } zoneID := zr.Zones[0].ID // Fetch records in zone recsURL := fmt.Sprintf("%s/records?zone_id=%s", p.apiBaseURL, zoneID) req, _ = http.NewRequestWithContext(ctx, http.MethodGet, recsURL, nil) req.Header.Set("Authorization", "Bearer "+p.token) resp, err = p.client.Do(req) if err != nil { return fmt.Errorf("fetch records: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("hetzner update failed with status: %s", resp.Status) } var rr recordsResponse if err := json.NewDecoder(resp.Body).Decode(&rr); err != nil { return fmt.Errorf("parsing records response: %w", err) } var recID string for _, rec := range rr.Records { if rec.Name == domain { recID = rec.ID break } } if recID == "" { return fmt.Errorf("record %s not found", domain) } // Update record value updateURL := fmt.Sprintf("%s/records/%s", p.apiBaseURL, recID) body := map[string]string{"value": ip} buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(body); err != nil { return fmt.Errorf("encode update body: %w", err) } req, _ = http.NewRequestWithContext(ctx, http.MethodPut, updateURL, buf) req.Header.Set("Authorization", "Bearer "+p.token) req.Header.Set("Content-Type", "application/json") resp, err = p.client.Do(req) if err != nil { return fmt.Errorf("update record: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("update API status: %s", resp.Status) } return nil }