summaryrefslogtreecommitdiff
path: root/internal/control/dns.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/control/dns.go')
-rw-r--r--internal/control/dns.go163
1 files changed, 163 insertions, 0 deletions
diff --git a/internal/control/dns.go b/internal/control/dns.go
new file mode 100644
index 0000000..f841d91
--- /dev/null
+++ b/internal/control/dns.go
@@ -0,0 +1,163 @@
+package control
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const porkbunAPIHost = "https://api.porkbun.com/api/json/v3"
+
+var porkbunAPIHostOverride string
+
+type DNSProvider interface {
+ EnsureRandomARecord(ctx context.Context, zone, prefix, ip string, ttl int) (string, error)
+ DeleteARecord(ctx context.Context, zone, name string) error
+}
+
+type PorkbunClient struct {
+ APIKey string
+ SecretAPIKey string
+ HTTPClient *http.Client
+}
+
+type porkbunResponse struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Records []map[string]any `json:"records"`
+ ID string `json:"id"`
+}
+
+func (c PorkbunClient) EnsureRandomARecord(ctx context.Context, zone, prefix, ip string, ttl int) (string, error) {
+ if err := c.validate(); err != nil {
+ return "", err
+ }
+ if ttl == 0 {
+ ttl = 600
+ }
+
+ for range 10 {
+ name := randomSubdomain(prefix)
+ records, err := c.retrieveRecordsByNameType(ctx, zone, "A", name)
+ if err != nil {
+ return "", err
+ }
+ if len(records) > 0 {
+ continue
+ }
+ if err := c.createRecord(ctx, zone, name, "A", ip, ttl); err != nil {
+ return "", err
+ }
+ return name + "." + zone, nil
+ }
+
+ return "", errors.New("failed to allocate unique subdomain")
+}
+
+func (c PorkbunClient) DeleteARecord(ctx context.Context, zone, name string) error {
+ if err := c.validate(); err != nil {
+ return err
+ }
+ return c.deleteByNameType(ctx, zone, "A", name)
+}
+
+func (c PorkbunClient) validate() error {
+ if strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.SecretAPIKey) == "" {
+ return errors.New("porkbun api keys are not configured")
+ }
+ return nil
+}
+
+func (c PorkbunClient) createRecord(ctx context.Context, zone, name, recordType, content string, ttl int) error {
+ payload := map[string]string{
+ "secretapikey": c.SecretAPIKey,
+ "apikey": c.APIKey,
+ "name": name,
+ "type": recordType,
+ "content": content,
+ "ttl": fmt.Sprintf("%d", ttl),
+ }
+ _, err := c.post(ctx, "/dns/create/"+zone, payload)
+ return err
+}
+
+func (c PorkbunClient) deleteByNameType(ctx context.Context, zone, recordType, name string) error {
+ payload := map[string]string{
+ "secretapikey": c.SecretAPIKey,
+ "apikey": c.APIKey,
+ }
+ _, err := c.post(ctx, "/dns/deleteByNameType/"+zone+"/"+recordType+"/"+name, payload)
+ return err
+}
+
+func (c PorkbunClient) retrieveRecordsByNameType(ctx context.Context, zone, recordType, name string) ([]map[string]any, error) {
+ payload := map[string]string{
+ "secretapikey": c.SecretAPIKey,
+ "apikey": c.APIKey,
+ }
+ resp, err := c.post(ctx, "/dns/retrieveByNameType/"+zone+"/"+recordType+"/"+name, payload)
+ if err != nil {
+ return nil, err
+ }
+ return resp.Records, nil
+}
+
+func (c PorkbunClient) post(ctx context.Context, path string, payload map[string]string) (*porkbunResponse, error) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ client := c.HTTPClient
+ if client == nil {
+ client = &http.Client{Timeout: 15 * time.Second}
+ }
+
+ baseURL := porkbunAPIHost
+ if porkbunAPIHostOverride != "" {
+ baseURL = porkbunAPIHostOverride
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var out porkbunResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("porkbun http %d: %s", resp.StatusCode, out.Message)
+ }
+ if strings.ToUpper(out.Status) != "SUCCESS" {
+ return nil, fmt.Errorf("porkbun api error: %s", out.Message)
+ }
+ return &out, nil
+}
+
+func randomSubdomain(prefix string) string {
+ if prefix == "" {
+ prefix = "vpn"
+ }
+ var buf [4]byte
+ if _, err := rand.Read(buf[:]); err != nil {
+ now := time.Now().UTC().UnixNano()
+ return fmt.Sprintf("%s-%x", prefix, now)
+ }
+ return prefix + "-" + hex.EncodeToString(buf[:])
+}