package hetzner import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) type Client struct { baseURL string http *http.Client } type Zone struct { ID string Name string } type Record struct { Value string `json:"value"` Comment string `json:"comment,omitempty"` } type RRSet struct { Name string `json:"name"` Type string `json:"type"` TTL *int `json:"ttl"` Records []Record `json:"records"` Protection struct { Change bool `json:"change"` } `json:"protection"` } type APIError struct { Status int Code string Message string } func (e *APIError) Error() string { if e.Code != "" { return fmt.Sprintf("Hetzner API %d (%s): %s", e.Status, e.Code, e.Message) } return fmt.Sprintf("Hetzner API %d: %s", e.Status, e.Message) } func New(baseURL string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{ Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, }, } } func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) { var result []Zone for page := 1; ; page++ { var response struct { Zones []struct { ID int64 `json:"id"` Name string `json:"name"` } `json:"zones"` Meta struct { Pagination struct { NextPage *int `json:"next_page"` } `json:"pagination"` } `json:"meta"` } query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"50"}} if err := c.doJSON(ctx, token, http.MethodGet, "/zones?"+query.Encode(), nil, &response); err != nil { return nil, err } for _, zone := range response.Zones { result = append(result, Zone{ID: strconv.FormatInt(zone.ID, 10), Name: zone.Name}) } if response.Meta.Pagination.NextPage == nil { break } page = *response.Meta.Pagination.NextPage - 1 } return result, nil } func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) { var result []RRSet for page := 1; ; page++ { var response struct { RRSets []RRSet `json:"rrsets"` Meta struct { Pagination struct { NextPage *int `json:"next_page"` } `json:"pagination"` } `json:"meta"` } query := url.Values{"page": {strconv.Itoa(page)}, "per_page": {"100"}} if err := c.doJSON(ctx, token, http.MethodGet, "/zones/"+url.PathEscape(zoneID)+"/rrsets?"+query.Encode(), nil, &response); err != nil { return nil, err } result = append(result, response.RRSets...) if response.Meta.Pagination.NextPage == nil { break } page = *response.Meta.Pagination.NextPage - 1 } return result, nil } func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordType string, ttl int, records []Record) error { body := struct { Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl"` Records []Record `json:"records"` }{Name: name, Type: recordType, TTL: ttl, Records: records} return c.doJSON(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body, nil) } func (c *Client) SetRRSetRecords(ctx context.Context, token, zoneID, name, recordType string, records []Record) error { body := struct { Records []Record `json:"records"` }{Records: records} return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body, nil) } func (c *Client) ChangeRRSetTTL(ctx context.Context, token, zoneID, name, recordType string, ttl int) error { body := struct { TTL int `json:"ttl"` }{TTL: ttl} return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body, nil) } func (c *Client) DeleteRRSet(ctx context.Context, token, zoneID, name, recordType string) error { return c.doJSON(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil, nil) } func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name, recordType string, records []Record) error { body := struct { Records []struct { Value string `json:"value"` Comment string `json:"comment"` } `json:"records"` }{Records: make([]struct { Value string `json:"value"` Comment string `json:"comment"` }, 0, len(records))} for _, record := range records { body.Records = append(body.Records, struct { Value string `json:"value"` Comment string `json:"comment"` }{Value: record.Value, Comment: record.Comment}) } return c.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body, nil) } func (c *Client) doJSON(ctx context.Context, token, method, path string, requestBody any, responseBody any) error { var body io.Reader if requestBody != nil { encoded, err := json.Marshal(requestBody) if err != nil { return err } body = bytes.NewReader(encoded) } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") if requestBody != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("request Hetzner API: %w", err) } defer resp.Body.Close() content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return fmt.Errorf("read Hetzner API response: %w", err) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return decodeAPIError(resp.StatusCode, content) } if responseBody != nil && len(content) > 0 { if err := json.Unmarshal(content, responseBody); err != nil { return fmt.Errorf("decode Hetzner API response: %w", err) } } return nil } func rrsetPath(zoneID, name, recordType string) string { return "/zones/" + url.PathEscape(zoneID) + "/rrsets/" + url.PathEscape(name) + "/" + url.PathEscape(recordType) } func decodeAPIError(status int, content []byte) error { var response struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } _ = json.Unmarshal(content, &response) message := strings.TrimSpace(response.Error.Message) if message == "" { message = http.StatusText(status) } return &APIError{Status: status, Code: response.Error.Code, Message: message} }