aboutsummaryrefslogtreecommitdiff
path: root/internal/porkbun
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-07-03 15:21:56 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-07-03 15:21:58 +0200
commit95dc66cb108a311065917362a00b86c08767303d (patch)
tree46ca65be6548224be0dd5a42bb86f2ecede14da8 /internal/porkbun
Add source controlHEADmain
Diffstat (limited to 'internal/porkbun')
-rw-r--r--internal/porkbun/client.go248
-rw-r--r--internal/porkbun/client_test.go271
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)
+ }
+}