package hetzner import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve" ) type Client struct { baseURL string http *http.Client actionPollInterval time.Duration actionTimeout time.Duration } 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) HTTPStatusCode() int { return e.Status } 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 { transport := http.DefaultTransport.(*http.Transport).Clone() transport.DialContext = netresolve.New().DialContext transport.IdleConnTimeout = 30 * time.Second return &Client{ baseURL: strings.TrimRight(baseURL, "/"), actionPollInterval: 500 * time.Millisecond, actionTimeout: 20 * time.Second, http: &http.Client{ Transport: transport, 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) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() 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 } if *response.Meta.Pagination.NextPage <= page { return nil, fmt.Errorf("Hetzner API pagination did not advance beyond page %d", page) } page = *response.Meta.Pagination.NextPage - 1 } return result, nil } func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() 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 } if *response.Meta.Pagination.NextPage <= page { return nil, fmt.Errorf("Hetzner API pagination did not advance beyond page %d", page) } 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.doAction(ctx, token, http.MethodPost, "/zones/"+url.PathEscape(zoneID)+"/rrsets", body) } 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.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body) } 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.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body) } func (c *Client) DeleteRRSet(ctx context.Context, token, zoneID, name, recordType string) error { return c.doAction(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), 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.doAction(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/update_records", body) } // ActionError reports a failed asynchronous operation even when its HTTP // request was accepted successfully. type ActionError struct { ID int64 Code string Message string } func (e *ActionError) ProviderActionID() int64 { return e.ID } func (e *ActionError) Error() string { return fmt.Sprintf("Hetzner action %d failed (%s): %s", e.ID, e.Code, e.Message) } type actionResponse struct { Action *struct { ID int64 `json:"id"` Status string `json:"status"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` } `json:"action"` } func (c *Client) doAction(ctx context.Context, token, method, path string, body any) error { ctx, cancel := context.WithTimeout(ctx, c.actionTimeout) defer cancel() var response actionResponse if err := c.doJSON(ctx, token, method, path, body, &response); err != nil { return err } var actionID int64 for { action := response.Action if action == nil || action.ID <= 0 || (actionID != 0 && action.ID != actionID) { return fmt.Errorf("Hetzner API returned a missing or invalid action") } actionID = action.ID switch action.Status { case "success": return nil case "error": failure := &ActionError{ID: action.ID, Code: "unknown", Message: "operation failed"} if action.Error != nil { failure.Code, failure.Message = action.Error.Code, action.Error.Message } return failure case "running": default: return fmt.Errorf("Hetzner action %d returned an invalid status", actionID) } timer := time.NewTimer(c.actionPollInterval) select { case <-ctx.Done(): timer.Stop() return fmt.Errorf("wait for Hetzner action %d: %w", actionID, ctx.Err()) case <-timer.C: } response = actionResponse{} // Poll the accepted operation; never repeat a write whose outcome is unknown. if err := c.doJSON(ctx, token, http.MethodGet, "/zones/actions/"+strconv.FormatInt(actionID, 10), nil, &response); err != nil { return fmt.Errorf("check Hetzner action %d: %w", actionID, err) } } } 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)+1)) if err != nil { return fmt.Errorf("read Hetzner API response: %w", err) } if len(content) > 1<<20 { return fmt.Errorf("Hetzner API response exceeds size limit") } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return decodeAPIError(resp.StatusCode, content) } if responseBody != nil { 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} }