diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:56 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:58 +0200 |
| commit | 95dc66cb108a311065917362a00b86c08767303d (patch) | |
| tree | 46ca65be6548224be0dd5a42bb86f2ecede14da8 /internal/porkbun | |
Diffstat (limited to 'internal/porkbun')
| -rw-r--r-- | internal/porkbun/client.go | 248 | ||||
| -rw-r--r-- | internal/porkbun/client_test.go | 271 |
2 files changed, 519 insertions, 0 deletions
diff --git a/internal/porkbun/client.go b/internal/porkbun/client.go new file mode 100644 index 0000000..93c54eb --- /dev/null +++ b/internal/porkbun/client.go @@ -0,0 +1,248 @@ +package porkbun + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "strconv" + "strings" + "time" +) + +const DefaultBaseURL = "https://api.porkbun.com/api/json/v3" + +type Client struct { + apiKey string + secretAPIKey string + baseURL string + httpClient *http.Client +} + +type Option func(*Client) + +func WithBaseURL(u string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") } +} + +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { c.httpClient = h } +} + +func NewClient(apiKey, secretAPIKey string, opts ...Option) *Client { + c := &Client{ + apiKey: apiKey, + secretAPIKey: secretAPIKey, + baseURL: DefaultBaseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + for _, opt := range opts { + opt(c) + } + return c +} + +type APIError struct { + HTTPStatus int + Status string + Message string +} + +func (e *APIError) Error() string { + msg := e.Message + if msg == "" { + msg = "no error message returned" + } + return fmt.Sprintf("porkbun API error (HTTP %d, status %q): %s", e.HTTPStatus, e.Status, msg) +} + +type Record struct { + ID string + Name string + Type string + Content string + TTL int + Prio int +} + +type RecordSpec struct { + Name string + Type string + Content string + TTL int + Prio int +} + +func (c *Client) Ping(ctx context.Context) (string, error) { + var out struct { + YourIP string `json:"yourIp"` + } + if err := c.post(ctx, "/ping", nil, &out); err != nil { + return "", err + } + return out.YourIP, nil +} + +func (c *Client) CreateRecord(ctx context.Context, domain string, spec RecordSpec) (string, error) { + body := map[string]any{ + "name": spec.Name, + "type": spec.Type, + "content": spec.Content, + // The API documents ttl and prio as strings. + "ttl": strconv.Itoa(spec.TTL), + "prio": strconv.Itoa(spec.Prio), + } + var out struct { + ID flexString `json:"id"` + } + if err := c.post(ctx, "/dns/create/"+url(domain), body, &out); err != nil { + return "", err + } + if out.ID == "" { + return "", fmt.Errorf("porkbun API reported success creating a record on %q but returned no record ID", domain) + } + return string(out.ID), nil +} + +func (c *Client) EditRecord(ctx context.Context, domain, id string, spec RecordSpec) error { + body := map[string]any{ + "type": spec.Type, + "content": spec.Content, + "ttl": strconv.Itoa(spec.TTL), + "prio": strconv.Itoa(spec.Prio), + } + return c.post(ctx, "/dns/edit/"+url(domain)+"/"+url(id), body, nil) +} + +func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error { + return c.post(ctx, "/dns/delete/"+url(domain)+"/"+url(id), nil, nil) +} + +func (c *Client) GetRecord(ctx context.Context, domain, id string) (*Record, error) { + var out struct { + Records []wireRecord `json:"records"` + } + if err := c.post(ctx, "/dns/retrieve/"+url(domain)+"/"+url(id), nil, &out); err != nil { + return nil, err + } + if len(out.Records) == 0 { + return nil, nil + } + rec := out.Records[0].toRecord() + return &rec, nil +} + +type wireRecord struct { + ID flexString `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL flexInt `json:"ttl"` + Prio flexInt `json:"prio"` +} + +func (w wireRecord) toRecord() Record { + return Record{ + ID: string(w.ID), + Name: w.Name, + Type: w.Type, + Content: w.Content, + TTL: int(w.TTL), + Prio: int(w.Prio), + } +} + +type flexString string + +func (f *flexString) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err == nil { + *f = flexString(s) + return nil + } + var n json.Number + if err := json.Unmarshal(b, &n); err == nil { + *f = flexString(n.String()) + return nil + } + return fmt.Errorf("expected string or number, got %s", b) +} + +type flexInt int + +func (f *flexInt) UnmarshalJSON(b []byte) error { + if string(b) == "null" || string(b) == `""` { + *f = 0 + return nil + } + var n int + if err := json.Unmarshal(b, &n); err == nil { + *f = flexInt(n) + return nil + } + var s string + if err := json.Unmarshal(b, &s); err == nil { + v, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return fmt.Errorf("expected numeric string, got %q", s) + } + *f = flexInt(v) + return nil + } + return fmt.Errorf("expected number or numeric string, got %s", b) +} + +func url(segment string) string { + return neturl.PathEscape(segment) +} + +func (c *Client) post(ctx context.Context, path string, body map[string]any, out any) error { + payload := map[string]any{ + "apikey": c.apiKey, + "secretapikey": c.secretAPIKey, + } + for k, v := range body { + payload[k] = v + } + buf, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encoding request for %s: %w", path, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("building request for %s: %w", path, err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("calling porkbun API %s: %w", path, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("reading response from %s: %w", path, err) + } + + var envelope struct { + Status string `json:"status"` + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return fmt.Errorf("porkbun API %s returned HTTP %d with an unparseable body: %w", path, resp.StatusCode, err) + } + if envelope.Status != "SUCCESS" { + return &APIError{HTTPStatus: resp.StatusCode, Status: envelope.Status, Message: envelope.Message} + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("decoding response from %s: %w", path, err) + } + } + return nil +} diff --git a/internal/porkbun/client_test.go b/internal/porkbun/client_test.go new file mode 100644 index 0000000..a33f698 --- /dev/null +++ b/internal/porkbun/client_test.go @@ -0,0 +1,271 @@ +package porkbun + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +const ( + testAPIKey = "pk1_test" + testSecretKey = "sk1_test" +) + +// newTestClient starts an httptest server running handler and returns a +// client pointed at it. +func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return NewClient(testAPIKey, testSecretKey, WithBaseURL(srv.URL)) +} + +// decodeBody decodes the request body and verifies the credentials were sent. +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding request body: %v", err) + } + if body["apikey"] != testAPIKey || body["secretapikey"] != testSecretKey { + t.Errorf("credentials not sent: got apikey=%v secretapikey=%v", body["apikey"], body["secretapikey"]) + } + return body +} + +func TestCreateRecord(t *testing.T) { + var gotPath string + var gotBody map[string]any + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + gotBody = decodeBody(t, r) + // Porkbun documents the ID as a string but returns a JSON number. + w.Write([]byte(`{"status":"SUCCESS","id":106926652}`)) + }) + + id, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{ + Name: "www", Type: "A", Content: "192.0.2.1", TTL: 600, Prio: 0, + }) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if id != "106926652" { + t.Errorf("expected id 106926652, got %q", id) + } + if gotPath != "/dns/create/example.com" { + t.Errorf("unexpected path %q", gotPath) + } + for key, want := range map[string]any{ + "name": "www", "type": "A", "content": "192.0.2.1", "ttl": "600", "prio": "0", + } { + if gotBody[key] != want { + t.Errorf("body[%q] = %v, want %v", key, gotBody[key], want) + } + } +} + +func TestCreateRecordStringID(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","id":"42"}`)) + }) + id, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "A", Content: "192.0.2.1", TTL: 600}) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if id != "42" { + t.Errorf("expected id 42, got %q", id) + } +} + +func TestCreateRecordMissingID(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + if _, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "A", Content: "192.0.2.1", TTL: 600}); err == nil { + t.Fatal("expected an error when the API returns no record ID") + } +} + +func TestAPIError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"status":"ERROR","message":"Invalid record type."}`)) + }) + _, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "BOGUS", Content: "x", TTL: 600}) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Status != "ERROR" || apiErr.Message != "Invalid record type." { + t.Errorf("unexpected APIError: %+v", apiErr) + } +} + +func TestEditRecord(t *testing.T) { + var gotPath string + var gotBody map[string]any + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotBody = decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + + err := client.EditRecord(context.Background(), "example.com", "42", RecordSpec{ + Name: "www", Type: "A", Content: "192.0.2.2", TTL: 700, Prio: 0, + }) + if err != nil { + t.Fatalf("EditRecord: %v", err) + } + if gotPath != "/dns/edit/example.com/42" { + t.Errorf("unexpected path %q", gotPath) + } + // The edit endpoint does not accept a name; it must not be sent. + if _, ok := gotBody["name"]; ok { + t.Errorf("edit request must not include a name field, got %v", gotBody["name"]) + } + if gotBody["content"] != "192.0.2.2" || gotBody["ttl"] != "700" { + t.Errorf("unexpected edit body: %v", gotBody) + } +} + +func TestDeleteRecord(t *testing.T) { + var gotPath string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + if err := client.DeleteRecord(context.Background(), "example.com", "42"); err != nil { + t.Fatalf("DeleteRecord: %v", err) + } + if gotPath != "/dns/delete/example.com/42" { + t.Errorf("unexpected path %q", gotPath) + } +} + +func TestGetRecord(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/dns/retrieve/example.com/42" { + t.Errorf("unexpected path %q", r.URL.Path) + } + // String-typed numeric fields, as documented. + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":"42","name":"www.example.com","type":"A","content":"192.0.2.1","ttl":"600","prio":"0","notes":""} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + want := Record{ID: "42", Name: "www.example.com", Type: "A", Content: "192.0.2.1", TTL: 600, Prio: 0} + if rec == nil || *rec != want { + t.Errorf("GetRecord = %+v, want %+v", rec, want) + } +} + +func TestGetRecordNumericFields(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":42,"name":"mail.example.com","type":"MX","content":"mx.example.com","ttl":600,"prio":10} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + want := Record{ID: "42", Name: "mail.example.com", Type: "MX", Content: "mx.example.com", TTL: 600, Prio: 10} + if rec == nil || *rec != want { + t.Errorf("GetRecord = %+v, want %+v", rec, want) + } +} + +func TestGetRecordNullPrio(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":"42","name":"example.com","type":"A","content":"192.0.2.1","ttl":"600","prio":null} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if rec.Prio != 0 { + t.Errorf("expected null prio to parse as 0, got %d", rec.Prio) + } +} + +func TestGetRecordNotFound(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "999") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if rec != nil { + t.Errorf("expected nil record for missing ID, got %+v", rec) + } +} + +func TestPing(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ping" { + t.Errorf("unexpected path %q", r.URL.Path) + } + decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS","yourIp":"203.0.113.7"}`)) + }) + ip, err := client.Ping(context.Background()) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if ip != "203.0.113.7" { + t.Errorf("expected ip 203.0.113.7, got %q", ip) + } +} + +func TestBaseURLTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`{"status":"SUCCESS","yourIp":"1.2.3.4"}`)) + })) + t.Cleanup(srv.Close) + + client := NewClient(testAPIKey, testSecretKey, WithBaseURL(srv.URL+"/")) + if _, err := client.Ping(context.Background()); err != nil { + t.Fatalf("Ping: %v", err) + } + if gotPath != "/ping" { + t.Errorf("trailing slash not normalized: path %q", gotPath) + } +} + +func TestUnparseableResponse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`<html>bad gateway</html>`)) + }) + if _, err := client.GetRecord(context.Background(), "example.com", "42"); err == nil { + t.Fatal("expected an error for a non-JSON response") + } +} + +func TestPathEscaping(t *testing.T) { + var gotPath string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + // A malicious "domain" must not be able to change the request path. + _ = client.DeleteRecord(context.Background(), "example.com/../../admin", "42") + if gotPath != "/dns/delete/example.com%2F..%2F..%2Fadmin/42" { + t.Errorf("path traversal not escaped: %q", gotPath) + } +} |