fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+103 -11
View File
@@ -11,11 +11,15 @@ import (
"strconv"
"strings"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/netresolve"
)
type Client struct {
baseURL string
http *http.Client
baseURL string
http *http.Client
actionPollInterval time.Duration
actionTimeout time.Duration
}
type Zone struct {
@@ -44,6 +48,8 @@ type APIError struct {
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)
@@ -52,10 +58,16 @@ func (e *APIError) Error() string {
}
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, "/"),
baseURL: strings.TrimRight(baseURL, "/"),
actionPollInterval: 500 * time.Millisecond,
actionTimeout: 20 * time.Second,
http: &http.Client{
Timeout: 15 * time.Second,
Transport: transport,
Timeout: 15 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
@@ -64,6 +76,8 @@ func New(baseURL string) *Client {
}
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 {
@@ -87,12 +101,17 @@ func (c *Client) ListZones(ctx context.Context, token string) ([]Zone, error) {
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 {
@@ -111,6 +130,9 @@ func (c *Client) ListRRSets(ctx context.Context, token, zoneID string) ([]RRSet,
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
@@ -123,25 +145,25 @@ func (c *Client) CreateRRSet(ctx context.Context, token, zoneID, name, recordTyp
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)
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.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/set_records", body, nil)
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.doJSON(ctx, token, http.MethodPost, rrsetPath(zoneID, name, recordType)+"/actions/change_ttl", body, nil)
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.doJSON(ctx, token, http.MethodDelete, rrsetPath(zoneID, name, recordType), nil, nil)
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 {
@@ -160,7 +182,74 @@ func (c *Client) UpdateRecordComments(ctx context.Context, token, zoneID, name,
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)
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 {
@@ -186,14 +275,17 @@ func (c *Client) doJSON(ctx context.Context, token, method, path string, request
return fmt.Errorf("request Hetzner API: %w", err)
}
defer resp.Body.Close()
content, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
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 && len(content) > 0 {
if responseBody != nil {
if err := json.Unmarshal(content, responseBody); err != nil {
return fmt.Errorf("decode Hetzner API response: %w", err)
}
+101 -2
View File
@@ -3,11 +3,107 @@ package hetzner
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
)
func TestDNSWritesWaitForActionCompletion(t *testing.T) {
operations := map[string]func(*Client) error{
"create": func(c *Client) error {
return c.CreateRRSet(context.Background(), "token", "123", "home", "A", 60, nil)
},
"records": func(c *Client) error {
return c.SetRRSetRecords(context.Background(), "token", "123", "home", "A", nil)
},
"ttl": func(c *Client) error { return c.ChangeRRSetTTL(context.Background(), "token", "123", "home", "A", 60) },
"comments": func(c *Client) error {
return c.UpdateRecordComments(context.Background(), "token", "123", "home", "A", nil)
},
"delete": func(c *Client) error { return c.DeleteRRSet(context.Background(), "token", "123", "home", "A") },
}
for name, operation := range operations {
t.Run(name, func(t *testing.T) {
for _, status := range []string{"success", "error"} {
t.Run(status, func(t *testing.T) {
var writes, polls int
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writes++
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`))
return
}
if r.URL.Path != "/zones/actions/42" || r.Header.Get("Authorization") != "Bearer token" {
t.Errorf("incorrect action polling request: %s", r.URL.Path)
}
polls++
if polls == 1 {
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`))
} else {
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"` + status + `","error":{"code":"action_failed","message":"failed later"}}}`))
}
}))
defer api.Close()
client := New(api.URL)
client.actionPollInterval = time.Millisecond
err := operation(client)
if status == "success" && err != nil {
t.Fatal(err)
}
var actionErr *ActionError
if status == "error" && (!errors.As(err, &actionErr) || actionErr.Code != "action_failed") {
t.Fatalf("lost action failure: %v", err)
}
if status == "error" && (diagnostics.Failure(err) != "provider_action" || diagnostics.ActionID(err) != 42) {
t.Fatal("action failure missing from safe diagnostics")
}
if writes != 1 || polls != 2 {
t.Fatalf("returned too early or repeated write: writes=%d polls=%d", writes, polls)
}
})
}
})
}
}
func TestActionWaitIsBoundedAndRejectsMalformedResponses(t *testing.T) {
for _, body := range []string{"", `{}`, `{"action":{"id":42,"status":"unknown"}}`, `{"action":{"status":"running"}}`, `{"action":{"id":42,"status":"running"}}`} {
t.Run(body, func(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
defer api.Close()
client := New(api.URL)
client.actionPollInterval = time.Millisecond
client.actionTimeout = 30 * time.Millisecond
if err := client.DeleteRRSet(context.Background(), "token", "123", "home", "A"); err == nil {
t.Fatal("unconfirmed operation reported success")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := client.DeleteRRSet(ctx, "token", "123", "home", "A"); !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation lost: %v", err)
}
})
}
}
func TestPaginationRejectsRepeatedPage(t *testing.T) {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"zones":[],"rrsets":[],"meta":{"pagination":{"next_page":1}}}`))
}))
defer api.Close()
client := New(api.URL)
if _, err := client.ListZones(context.Background(), "token"); err == nil {
t.Fatal("repeated zones page accepted")
}
if _, err := client.ListRRSets(context.Background(), "token", "123"); err == nil {
t.Fatal("repeated RRsets page accepted")
}
}
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
t.Parallel()
type capturedRequest struct {
@@ -33,7 +129,7 @@ func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"status":"running"}}`))
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}))
defer api.Close()
@@ -77,6 +173,7 @@ func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
body["path"] = r.URL.Path
captured <- body
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}))
defer api.Close()
@@ -115,6 +212,7 @@ func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
body["path"] = r.URL.Path
captured <- body
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}))
defer api.Close()
@@ -133,7 +231,8 @@ func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
captured := make(chan *http.Request, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured <- r
w.WriteHeader(http.StatusNoContent)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}))
defer api.Close()