fix: harden DynDNS recovery and publish v1.5.1
This commit is contained in:
1 parent
3383b91e49
commit
81def77e3c
37 files changed
+2415
-250
No files matched your search
+103
-11
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user