feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled
Container-Image veröffentlichen / container (push) Has been cancelled
This commit is contained in:
1 parent
9c60eaf3fe
commit
df7516e7ab
45 files changed
+7932
-1
No files matched your search
@@ -0,0 +1,221 @@
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Authorization string
|
||||
Records []Record
|
||||
}
|
||||
captured := make(chan capturedRequest, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Records []Record `json:"records"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
captured <- capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Authorization: r.Header.Get("Authorization"),
|
||||
Records: body.Records,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"action":{"status":"running"}}`))
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
err := client.SetRRSetRecords(
|
||||
context.Background(),
|
||||
"test-token",
|
||||
"123",
|
||||
"home",
|
||||
"A",
|
||||
[]Record{{Value: "198.51.100.42", Comment: "DynDNS"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("set RRSet records: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request.Method != http.MethodPost {
|
||||
t.Errorf("unexpected method: got %s, want POST", request.Method)
|
||||
}
|
||||
if request.Path != "/zones/123/rrsets/home/A/actions/set_records" {
|
||||
t.Errorf("unexpected path: %s", request.Path)
|
||||
}
|
||||
if request.Authorization != "Bearer test-token" {
|
||||
t.Errorf("unexpected authorization header: %q", request.Authorization)
|
||||
}
|
||||
if len(request.Records) != 1 || request.Records[0].Value != "198.51.100.42" || request.Records[0].Comment != "DynDNS" {
|
||||
t.Errorf("unexpected records payload: %#v", request.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := make(chan map[string]any, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body["method"] = r.Method
|
||||
body["path"] = r.URL.Path
|
||||
captured <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
if err := client.UpdateRecordComments(context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42"}}); err != nil {
|
||||
t.Fatalf("update record comment: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/update_records" {
|
||||
t.Fatalf("unexpected request: %#v", request)
|
||||
}
|
||||
records, ok := request["records"].([]any)
|
||||
if !ok || len(records) != 1 {
|
||||
t.Fatalf("unexpected records payload: %#v", request["records"])
|
||||
}
|
||||
record, ok := records[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected record payload: %#v", records[0])
|
||||
}
|
||||
comment, exists := record["comment"]
|
||||
if !exists || comment != "" {
|
||||
t.Errorf("empty comment must be sent explicitly, got %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := make(chan map[string]any, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body["method"] = r.Method
|
||||
body["path"] = r.URL.Path
|
||||
captured <- body
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
if err := client.ChangeRRSetTTL(context.Background(), "test-token", "123", "home", "A", 300); err != nil {
|
||||
t.Fatalf("change RRSet TTL: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/change_ttl" || request["ttl"] != float64(300) {
|
||||
t.Fatalf("unexpected request: %#v", request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
captured := make(chan *http.Request, 1)
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured <- r
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
client := New(api.URL)
|
||||
if err := client.DeleteRRSet(context.Background(), "test-token", "123", "www", "CNAME"); err != nil {
|
||||
t.Fatalf("delete RRSet: %v", err)
|
||||
}
|
||||
request := <-captured
|
||||
if request.Method != http.MethodDelete || request.URL.Path != "/zones/123/rrsets/www/CNAME" || request.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Fatalf("unexpected request: %s %s %q", request.Method, request.URL.Path, request.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user