package hetzner import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "strings" "git.cloonar.com/cloonar/updns/internal/config" pvd "git.cloonar.com/cloonar/updns/internal/provider" ) const defaultAPIBase = "https://dns.hetzner.com/api/v1" 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"` ZoneID string `json:"zone_id"` TTL int `json:"ttl"` } type recordsResponse struct { Records []record `json:"records"` } // NewProvider creates a Hetzner DNS provider using the official API. func NewProvider(cfg config.HetznerConfig) (pvd.Provider, error) { var token string hasToken := cfg.APIToken != "" hasTokenFile := cfg.APITokenFile != "" if hasToken && hasTokenFile { return nil, fmt.Errorf("hetzner config: provide api_token or api_token_file, not both") } if !hasToken && !hasTokenFile { return nil, fmt.Errorf("hetzner config: api_token or api_token_file must be provided") } if hasTokenFile { tokenBytes, err := os.ReadFile(cfg.APITokenFile) if err != nil { return nil, fmt.Errorf("reading hetzner token file %q: %w", cfg.APITokenFile, err) } token = strings.TrimSpace(string(tokenBytes)) } else { token = cfg.APIToken } if token == "" { // This case might happen if the file exists but is empty return nil, fmt.Errorf("hetzner api token is empty") } return &provider{token: token, client: http.DefaultClient, apiBaseURL: defaultAPIBase}, nil } // NewProviderWithURL creates a Hetzner provider with a custom API base URL (for testing). // Note: This testing helper still requires a direct token string. func NewProviderWithURL(token, apiBaseURL string) pvd.Provider { // Basic validation for the test helper if token == "" { panic("NewProviderWithURL requires a non-empty token for testing") } if apiBaseURL == "" { panic("NewProviderWithURL requires a non-empty apiBaseURL for testing") } 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:], ".") } subdomain := 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("Auth-API-Token", 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("Auth-API-Token", 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 == subdomain { recID = rec.ID break } } if recID == "" { // return fmt.Errorf("record %s not found", domain) // Create new record // Cut the last 2 parts of the domain name createURL := fmt.Sprintf("%s/records", p.apiBaseURL) body := record{ Name: subdomain, Type: "A", Value: ip, TTL: 60, ZoneID: zoneID, } buf := &bytes.Buffer{} if err := json.NewEncoder(buf).Encode(body); err != nil { return fmt.Errorf("encode create body: %w", err) } req, _ = http.NewRequestWithContext(ctx, http.MethodPost, createURL, buf) req.Header.Set("Auth-API-Token", p.token) req.Header.Set("Content-Type", "application/json") resp, err = p.client.Do(req) if err != nil { return fmt.Errorf("create record: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("create API status: %s", resp.Status) } } else { // Update record value updateURL := fmt.Sprintf("%s/records/%s", p.apiBaseURL, recID) body := record{ Name: subdomain, Type: "A", Value: ip, TTL: 60, ZoneID: zoneID, } 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("Auth-API-Token", 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 }