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
+}