Files
hetzner-api-dyndns-records-…/internal/hetzner/client.go
T
admin df7516e7ab
Container-Image veröffentlichen / container (push) Has been cancelled
feat: production-ready domain control dashboard
2026-07-12 20:56:45 +02:00

222 lines
6.2 KiB
Go

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}
}