summaryrefslogtreecommitdiff
path: root/provider/bunny.go
blob: c550a78cde3652770df81007564c9d5209cb9a0c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package provider

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/netip"
	"net/url"
)

type bunny struct {
	log *slog.Logger
	key string
}

var _ Provider = (*bunny)(nil)

func newBunny(log *slog.Logger, cfg Config) (*bunny, error) {
	key, err := cfg.key()
	if err != nil {
		return nil, fmt.Errorf("failed instantiating bunny provider: %w", err)
	}
	return &bunny{key: key, log: log}, nil
}

const bunnyAPI = "https://api.bunny.net/dnszone"

// bunny encodes record types numerically.
var bunnyTypes = map[RecordType]int{TypeA: 0, TypeAAAA: 1}

func (b *bunny) headers() map[string]string {
	return map[string]string{"AccessKey": b.key}
}

func (b *bunny) Ensure(zone, name string, rtype RecordType, addr netip.Addr) (string, bool, error) {
	b.log.Info("updating record via bunny.net api")

	var zones struct {
		Items []struct {
			ID     int64  `json:"Id"`
			Domain string `json:"Domain"`
		} `json:"Items"`
	}
	raw, err := request("GET", bunnyAPI+"?search="+url.QueryEscape(zone),
		b.headers(), nil)
	if err != nil {
		return "", false, err
	}
	if err := json.Unmarshal(raw, &zones); err != nil {
		return "", false, err
	}
	var zoneID int64 = -1
	for _, z := range zones.Items {
		if z.Domain == zone {
			zoneID = z.ID
		}
	}
	if zoneID < 0 {
		return "", false, fmt.Errorf("zone %s not found in bunny", zone)
	}

	var details struct {
		Records []struct {
			ID    int64  `json:"Id"`
			Type  int    `json:"Type"`
			Name  string `json:"Name"`
			Value string `json:"Value"`
		} `json:"Records"`
	}
	raw, err = request("GET", fmt.Sprintf("%s/%d", bunnyAPI, zoneID), b.headers(), nil)
	if err != nil {
		return "", false, fmt.Errorf("failed to request zone details from bunny: %w", err)
	}
	if err := json.Unmarshal(raw, &details); err != nil {
		return "", false, fmt.Errorf("received malformed zone details from bunny: %w", err)
	}

	body, _ := json.Marshal(map[string]any{
		"Type":  bunnyTypes[rtype],
		"Name":  name,
		"Value": addr.String(),
		"Ttl":   300,
	})

	for _, r := range details.Records {
		if r.Type != bunnyTypes[rtype] || r.Name != name {
			continue
		}
		if r.Value == addr.String() {
			return r.Value, false, nil
		}

		b.log.Info(
			"bunny knows this record, attempting update",
			slog.Group(
				"bunny",
				slog.Int64("id", r.ID),
				slog.Int("type", r.Type),
				slog.String("name", r.Name),
				slog.String("value", r.Value),
			),
		)

		// if found, update
		_, err = request("POST", fmt.Sprintf("%s/%d/records/%d", bunnyAPI, zoneID, r.ID),
			b.headers(), bytes.NewReader(body))
		return r.Value, true, err
	}

	b.log.Info("bunny does not know this record, attempting to create")

	// if not found, create
	_, err = request("PUT", fmt.Sprintf("%s/%d/records", bunnyAPI, zoneID),
		b.headers(), bytes.NewReader(body))
	return "", true, err
}