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[:]) }