~bsprague/porkbun-import

1b8f02189ae179df5f69444d1a3362ef020f9fb7 — Brandon Sprague 10 months ago main
Basics of functional Porkbun import stuff
6 files changed, 423 insertions(+), 0 deletions(-)

A LICENSE
A README.md
A go.mod
A go.sum
A main.go
A porkbun/porkbun.go
A  => LICENSE +21 -0
@@ 1,21 @@
MIT License

Copyright (c) 2023 Brandon Sprague

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

A  => README.md +22 -0
@@ 1,22 @@
# Porkbun Import

A simple tool for importing DNS records from Google Domains (exported as Zone/BIND files) to Porkbun.

It's pretty hacky (simple, manual parsing of BIND files) and should be used very carefully.

## Example Usage

```bash
./porkbun-import path/to/yourdomain.com.zone yourdomain.com sk1_abc123 pk1_xyz321
```

## Why?

[Google Domains is being sold to Squarespace](https://support.google.com/domains/answer/13689670?hl=en), and I've been trying to move away from Google stuff in general, so here we are. [Porkbun](https://porkbun.com/) seem like cool folk, even if they're maybe trying a _bit_ too hard.

## Assorted TODOs

- [ ] Clean up `porkbun` package and spin out
  - Combine with `porkbun` package in [cert-manager-webhook-porkbun](https://github.com/bcspragu/cert-manager-webhook-porkbun), where this came from
- [ ] Make a more cohesive CLI, with other options for passing in creds
- [ ] Use a `time.Ticker`-based rate-limiting scheme
\ No newline at end of file

A  => go.mod +5 -0
@@ 1,5 @@
module git.sr.ht/~bsprague/porkbun-import

go 1.20

require golang.org/x/net v0.15.0

A  => go.sum +2 -0
@@ 1,2 @@
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=

A  => main.go +170 -0
@@ 1,170 @@
package main

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"strconv"
	"strings"
	"time"

	"git.sr.ht/~bsprague/porkbun-import/porkbun"
)

type Record struct {
	Name     string
	TTL      int
	Class    string
	Type     string
	Priority int
	Content  string
}

func main() {
	if err := run(os.Args); err != nil {
		log.Fatal(err)
	}
}

func run(args []string) error {
	switch len(args) {
	case 0:
		return errors.New("no args given")
	case 5:
		// The correct number of args
	default:
		// Ex: ./porkbun-import yourdomain.com.zone yourdomain.com sk1_abc123 pk1_xyz321
		return errors.New("usage: ./porkbun-import <path to zone-file> <domain name> <secret key> <API key>")
	}

	ctx := context.Background()

	zonePath, domainName, secretKey, apiKey := args[1], args[2], args[3], args[4]

	client := porkbun.New(secretKey, apiKey)

	file, err := os.Open(zonePath)
	if err != nil {
		return fmt.Errorf("failed to open zone file: %w", err)
	}
	defer file.Close()

	var records []Record
	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		if strings.HasPrefix(line, ";") || line == "" {
			continue
		}

		var parseErr error
		atoi := func(in string) int {
			if parseErr != nil {
				return 0
			}
			v, err := strconv.Atoi(in)
			if err != nil {
				parseErr = err
				return 0
			}
			return v
		}

		fields := strings.Fields(line)
		var record Record
		switch typ := fields[3]; typ {
		case "A", "CNAME":
			if len(fields) != 5 {
				return fmt.Errorf("unexpected number of fields %d", len(fields))
			}
			record = Record{
				Name:    fields[0],
				TTL:     atoi(fields[1]),
				Class:   fields[2],
				Type:    fields[3],
				Content: fields[4],
			}
		case "TXT", "SPF": // SPF records don't appear to be real, but Google Domains has them
			if len(fields) < 5 {
				return fmt.Errorf("unexpected number of fields %d", len(fields))
			}
			val := strings.Join(fields[4:], " ")
			record = Record{
				Name:    fields[0],
				TTL:     atoi(fields[1]),
				Class:   fields[2],
				Type:    fields[3],
				Content: val,
			}
		case "MX":
			if len(fields) != 6 {
				return fmt.Errorf("unexpected number of fields %d", len(fields))
			}
			record = Record{
				Name:     fields[0],
				TTL:      atoi(fields[1]),
				Class:    fields[2],
				Type:     fields[3],
				Priority: atoi(fields[4]),
				Content:  fields[5],
			}
		default:
			return fmt.Errorf("unknown record type %q", typ)
		}
		if parseErr != nil {
			return fmt.Errorf("failed to parse record: %w", parseErr)
		}
		records = append(records, record)
	}

	if err = scanner.Err(); err != nil {
		return fmt.Errorf("failed to read file: %w", err)
	}

	for _, record := range records {
		resp, err := client.CreateDNSRecord(ctx, domainName, &porkbun.NewDNSRecord{
			Name:     strings.TrimSuffix(strings.TrimSuffix(record.Name, domainName+"."), "."),
			Type:     record.Type,
			Content:  record.Content,
			TTL:      strconv.Itoa(record.TTL),
			Priority: strconv.Itoa(record.Priority),
		})
		if err != nil {
			return fmt.Errorf("failed to add record: %w\n\nName: %s, TTL: %d, Class: %s, Type: %s, Prio: %d Content: %s\n", err,
				record.Name, record.TTL, record.Class, record.Type, record.Priority, record.Content)
		}
		fmt.Printf("Created record %q %q\n\tName: %s, TTL: %d, Class: %s, Type: %s, Prio: %d Content: %s\n\n", resp.Status, resp.ID,
			record.Name, record.TTL, record.Class, record.Type, record.Priority, record.Content)
		time.Sleep(2 * time.Second)
	}

	return nil
}

// Keeping this around for posterity
func deleteAllRecords(ctx context.Context, client *porkbun.Client, domain string) error {
	recResp, err := client.RetrieveDNSRecordsByDomain(ctx, domain)
	if err != nil {
		return fmt.Errorf("failed to load DNS records for domain: %w", err)
	}
	// Move these checks into the `porkbun` package
	if recResp.Status != "SUCCESS" {
		return fmt.Errorf("failed to load records: %q", recResp.Status)
	}
	for _, v := range recResp.Records {
		dResp, err := client.DeleteDNSRecordByDomainID(ctx, domain, v.ID)
		if err != nil {
			return fmt.Errorf("failed to delete DNS record: %w", err)
		}
		if dResp.Status != "SUCCESS" {
			return fmt.Errorf("failed to delete record: %q", dResp.Status)
		}
		fmt.Printf("deleted record %q\n", v.ID)
		time.Sleep(2 * time.Second)
	}

	return nil
}

A  => porkbun/porkbun.go +203 -0
@@ 1,203 @@
package porkbun

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"

	"golang.org/x/net/context/ctxhttp"
)

type Client struct {
	secretAPIKey string
	apiKey       string
	http         *http.Client
}

const baseURL = "https://porkbun.com/api/json/v3/"

func New(secretAPIKey, apiKey string) *Client {
	return &Client{
		secretAPIKey: secretAPIKey,
		apiKey:       apiKey,
		http:         &http.Client{},
	}
}

type BaseRequest struct {
	SecretAPIKey string `json:"secretapikey"`
	APIKey       string `json:"apikey"`
}

type PingResponse struct {
	Status string `json:"status"`
	IP     string `json:"yourIp"`
}

func (c *Client) Ping(ctx context.Context) (*PingResponse, error) {
	var req bytes.Buffer
	if err := json.NewEncoder(&req).Encode(c.baseRequest()); err != nil {
		return nil, fmt.Errorf("failed to encode request: %w", err)
	}

	httpResp, err := ctxhttp.Post(ctx, c.http, baseURL+"ping", "application/json", &req)
	if err != nil {
		return nil, fmt.Errorf("failed to hit ping endpoint: %w", err)
	}
	defer httpResp.Body.Close()

	var resp *PingResponse
	if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
		return nil, fmt.Errorf("failed to decode JSON response: %w", err)
	}
	return resp, nil
}

func (c *Client) baseRequest() *BaseRequest {
	return &BaseRequest{
		SecretAPIKey: c.secretAPIKey,
		APIKey:       c.apiKey,
	}
}

type RetrieveDNSRecordsResponse struct {
	Status  string      `json:"status"`
	Records []DNSRecord `json:"records"`
}

type DNSRecord struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Type     string `json:"type"`
	Content  string `json:"content"`
	TTL      string `json:"ttl"`
	Priority string `json:"prio"`
	Notes    string `json:"notes"`
}

func (c *Client) RetrieveDNSRecordsByDomain(ctx context.Context, domain string) (*RetrieveDNSRecordsResponse, error) {
	if strings.Contains(domain, "/") {
		return nil, errors.New("invalid domain given")
	}

	return c.retrieveDNSRecords(ctx, "dns/retrieve/"+domain)
}

func (c *Client) RetrieveDNSRecordsByDomainSubdomainType(ctx context.Context, domain, subdomain, typ string) (*RetrieveDNSRecordsResponse, error) {
	if strings.Contains(domain, "/") {
		return nil, errors.New("invalid domain given")
	}
	if strings.Contains(subdomain, "/") {
		return nil, errors.New("invalid subdomain given")
	}
	if strings.Contains(typ, "/") {
		return nil, errors.New("invalid type given")
	}
	return c.retrieveDNSRecords(ctx, "dns/retrieveByNameType/"+domain+"/"+typ+"/"+subdomain)
}

func (c *Client) retrieveDNSRecords(ctx context.Context, path string) (*RetrieveDNSRecordsResponse, error) {
	var req bytes.Buffer
	if err := json.NewEncoder(&req).Encode(c.baseRequest()); err != nil {
		return nil, fmt.Errorf("failed to encode request: %w", err)
	}

	httpResp, err := ctxhttp.Post(ctx, c.http, baseURL+path, "application/json", &req)
	if err != nil {
		return nil, fmt.Errorf("failed to hit retrieve DNS records endpoint: %w", err)
	}
	defer httpResp.Body.Close()

	var resp *RetrieveDNSRecordsResponse
	if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
		return nil, fmt.Errorf("failed to decode JSON response: %w", err)
	}
	return resp, nil
}

type NewDNSRecord struct {
	Name     string `json:"name"`
	Type     string `json:"type"`
	Content  string `json:"content"`
	TTL      string `json:"ttl"`
	Priority string `json:"prio"`
}

type CreateDNSRecordRequest struct {
	*BaseRequest
	*NewDNSRecord
}

type CreateDNSRecordResponse struct {
	Status string `json:"status"`
	ID     int    `json:"id"`
}

func (c *Client) CreateDNSRecord(ctx context.Context, domain string, record *NewDNSRecord) (*CreateDNSRecordResponse, error) {
	if strings.Contains(domain, "/") {
		return nil, errors.New("invalid domain given")
	}

	createReq := &CreateDNSRecordRequest{
		BaseRequest:  c.baseRequest(),
		NewDNSRecord: record,
	}
	var req bytes.Buffer
	if err := json.NewEncoder(&req).Encode(createReq); err != nil {
		return nil, fmt.Errorf("failed to encode request: %w", err)
	}

	httpResp, err := ctxhttp.Post(ctx, c.http, baseURL+"dns/create/"+domain, "application/json", &req)
	if err != nil {
		return nil, fmt.Errorf("failed to hit reate DNS record endpoint: %w", err)
	}
	defer httpResp.Body.Close()

	var (
		resp *CreateDNSRecordResponse
		buf  bytes.Buffer
	)
	tr := io.TeeReader(httpResp.Body, &buf)
	if err := json.NewDecoder(tr).Decode(&resp); err != nil {
		return nil, fmt.Errorf("failed to decode JSON response: %w\nresp: %s", err, buf.String())
	}
	if resp.Status != "SUCCESS" {
		return nil, fmt.Errorf("non-SUCCESS response %q, body %q", resp.Status, buf.String())
	}
	return resp, nil
}

type DeleteDNSRecordResponse struct {
	Status string `json:"status"`
}

func (c *Client) DeleteDNSRecordByDomainID(ctx context.Context, domain, id string) (*DeleteDNSRecordResponse, error) {
	if strings.Contains(domain, "/") {
		return nil, errors.New("invalid domain given")
	}
	if strings.Contains(id, "/") {
		return nil, errors.New("invalid id given")
	}

	var req bytes.Buffer
	if err := json.NewEncoder(&req).Encode(c.baseRequest()); err != nil {
		return nil, fmt.Errorf("failed to encode request: %w", err)
	}

	httpResp, err := ctxhttp.Post(ctx, c.http, baseURL+"dns/delete/"+domain+"/"+id, "application/json", &req)
	if err != nil {
		return nil, fmt.Errorf("failed to hit delete DNS record endpoint: %w", err)
	}
	defer httpResp.Body.Close()

	var resp *DeleteDNSRecordResponse
	if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil {
		return nil, fmt.Errorf("failed to decode JSON response: %w", err)
	}
	return resp, nil
}