package server import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // PorkbunDNS manages TXT records through Porkbun's DNS API // (https://api.porkbun.com/api/json/v3). type PorkbunDNS struct { APIKey string SecretKey string Zone string BaseURL string // default https://api.porkbun.com/api/json/v3 HTTP *http.Client } const porkbunDefaultBaseURL = "https://api.porkbun.com/api/json/v3" func (p *PorkbunDNS) base() string { if p.BaseURL != "" { return p.BaseURL } return porkbunDefaultBaseURL } func (p *PorkbunDNS) client() *http.Client { if p.HTTP != nil { return p.HTTP } return &http.Client{Timeout: 20 * time.Second} } func (p *PorkbunDNS) post(ctx context.Context, path string, body map[string]string) ([]byte, error) { body["apikey"] = p.APIKey body["secretapikey"] = p.SecretKey raw, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.base()+path, bytes.NewReader(raw)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := p.client().Do(req) if err != nil { return nil, err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("porkbun %s: HTTP %d: %s", path, resp.StatusCode, data) } var result struct { Status string `json:"status"` Message string `json:"message"` } if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("porkbun %s: unreadable response: %w", path, err) } if result.Status != "SUCCESS" { return nil, fmt.Errorf("porkbun %s: %s: %s", path, result.Status, result.Message) } return data, nil } func (p *PorkbunDNS) CreateTXT(ctx context.Context, name, value string) error { relative, err := relativeRecordName(name, p.Zone) if err != nil { return err } _, err = p.post(ctx, "/dns/create/"+p.Zone, map[string]string{ "name": relative, "type": "TXT", "content": value, "ttl": "600", }) return err } func (p *PorkbunDNS) DeleteTXT(ctx context.Context, name string) error { relative, err := relativeRecordName(name, p.Zone) if err != nil { return err } _, err = p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{}) return err } type porkbunRecord struct { Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` } // SweepChallenges removes every TXT record at a _acme-challenge.* name in the // zone. It runs at startup, before this process starts any issuance, so any // such records are leftovers from an interrupted attempt (single-node use). func (p *PorkbunDNS) SweepChallenges(ctx context.Context) (int, error) { data, err := p.post(ctx, "/dns/retrieve/"+p.Zone, map[string]string{}) if err != nil { return 0, err } var result struct { Records []porkbunRecord `json:"records"` } if err := json.Unmarshal(data, &result); err != nil { return 0, fmt.Errorf("porkbun retrieve: %w", err) } seen := map[string]struct{}{} removed := 0 for _, record := range result.Records { if record.Type != "TXT" { continue } name := strings.TrimSuffix(record.Name, ".") if !strings.HasPrefix(name, "_acme-challenge.") { continue } relative, err := relativeRecordName(name, p.Zone) if err != nil { // Only records inside this zone are ours to delete. continue } if _, done := seen[relative]; done { continue } seen[relative] = struct{}{} if _, err := p.post(ctx, "/dns/deleteByNameType/"+p.Zone+"/TXT/"+relative, map[string]string{}); err != nil { return removed, err } removed++ } return removed, nil }