package plesk import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "github.com/libdns/libdns" ) // Provider implements the libdns interfaces for Plesk. type Provider struct { // BaseURL is your Plesk REST API endpoint, like: // "https://my-plesk-server:8443/api/v2" BaseURL string `json:"base_url,omitempty"` // SecretToken is your Plesk API token (or pass user/pass if needed). // Example: "abcdefghijkl123456789" SecretToken string `json:"secret_token,omitempty"` // HTTPClient allows custom HTTP client overrides (timeouts, etc.) // If nil, we’ll use http.DefaultClient. HTTPClient *http.Client } // Ensure Provider implements the libdns interfaces you need. // (You can also implement RecordSetter if needed, but typically // for ACME challenges you only need Getter/Appender/Deleter.) var ( _ libdns.RecordGetter = &Provider{} _ libdns.RecordAppender = &Provider{} _ libdns.RecordDeleter = &Provider{} ) // pleskZone represents a single DNS zone (partial struct). type pleskZone struct { ID int `json:"id"` Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` Modified string `json:"modified"` } // pleskRecord represents a DNS record object in Plesk (partial struct). type pleskRecord struct { ID int `json:"id,omitempty"` Type string `json:"type,omitempty"` // e.g. "TXT" Host string `json:"host,omitempty"` // e.g. "_acme-challenge" Value string `json:"value,omitempty"` // The content, e.g. the ACME keyAuth // You can add more fields if needed, such as "ttl" or "opt" } // doRequest is a helper that handles HTTP requests to the Plesk REST API. func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) { if p.HTTPClient == nil { p.HTTPClient = http.DefaultClient } fullURL := strings.TrimSuffix(p.BaseURL, "/") + "/" + strings.TrimPrefix(path, "/") var reqBody []byte var err error if body != nil { reqBody, err = json.Marshal(body) if err != nil { return nil, fmt.Errorf("plesk: failed to marshal request body: %w", err) } } req, err := http.NewRequestWithContext(ctx, method, fullURL, bytes.NewReader(reqBody)) if err != nil { return nil, fmt.Errorf("plesk: failed to create request: %w", err) } // Example auth using Bearer token. Adapt if you need Basic Auth, etc. if p.SecretToken != "" { req.Header.Set("Authorization", "Bearer "+p.SecretToken) } req.Header.Set("Accept", "application/json") if method == http.MethodPost || method == http.MethodPatch || method == http.MethodPut { req.Header.Set("Content-Type", "application/json") } resp, err := p.HTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("plesk: HTTP request error: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("plesk: failed to read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("plesk: API returned status %d: %s", resp.StatusCode, string(respBody)) } return respBody, nil } // findZoneID attempts to locate the zone by domain name in Plesk. // Returns the integer zone ID if found, or an error otherwise. func (p *Provider) findZoneID(ctx context.Context, zone string) (int, error) { // Plesk might store domain "example.com" as "example.com." or vice versa. // Typically, we might need to remove trailing dots, or handle them carefully. domain := strings.TrimSuffix(zone, ".") // Example endpoint: GET /dns-zones?search= query := url.Values{} query.Set("search", domain) path := fmt.Sprintf("dns-zones?%s", query.Encode()) respBody, err := p.doRequest(ctx, http.MethodGet, path, nil) if err != nil { return 0, fmt.Errorf("plesk: error searching zone: %w", err) } var zones []pleskZone if err := json.Unmarshal(respBody, &zones); err != nil { return 0, fmt.Errorf("plesk: error unmarshaling zone list: %w", err) } for _, z := range zones { // If the zone name matches exactly, or if it matches after trimming. if strings.EqualFold(strings.TrimSuffix(z.Name, "."), domain) { return z.ID, nil } } return 0, fmt.Errorf("plesk: zone for domain '%s' not found", zone) } // GetRecords lists DNS records from Plesk in the given zone. func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) { zoneID, err := p.findZoneID(ctx, zone) if err != nil { return nil, err } // GET /dns-zones//records path := fmt.Sprintf("dns-zones/%d/records", zoneID) respBody, err := p.doRequest(ctx, http.MethodGet, path, nil) if err != nil { return nil, err } var presRecords []pleskRecord if err := json.Unmarshal(respBody, &presRecords); err != nil { return nil, fmt.Errorf("plesk: error unmarshaling DNS records: %w", err) } // Convert from pleskRecord to libdns.Record var results []libdns.Record for _, r := range presRecords { rec := libdns.Record{ ID: fmt.Sprintf("%d", r.ID), Type: r.Type, Name: r.Host, Value: r.Value, // If Plesk returns TTL or similar, map it here. // TTL: time.Duration(r.TTL) * time.Second, } results = append(results, rec) } return results, nil } // AppendRecords creates the given records in the specified DNS zone. func (p *Provider) AppendRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) { zoneID, err := p.findZoneID(ctx, zone) if err != nil { return nil, err } var created []libdns.Record for _, r := range recs { // Prepare JSON for creating a record in Plesk // POST /dns-zones//records request := pleskRecord{ Type: strings.ToUpper(r.Type), Host: r.Name, Value: r.Value, // Add TTL if desired, etc. } path := fmt.Sprintf("dns-zones/%d/records", zoneID) respBody, err := p.doRequest(ctx, http.MethodPost, path, request) if err != nil { return created, fmt.Errorf("plesk: error creating record '%s': %w", r.Name, err) } // Plesk typically returns the created record. Let’s parse it. var createdRecord pleskRecord if err := json.Unmarshal(respBody, &createdRecord); err != nil { return created, fmt.Errorf("plesk: error parsing create-record response: %w", err) } // Convert back to libdns.Record. out := libdns.Record{ ID: fmt.Sprintf("%d", createdRecord.ID), Type: createdRecord.Type, Name: createdRecord.Host, Value: createdRecord.Value, } created = append(created, out) } return created, nil } // DeleteRecords removes the given records from the specified DNS zone. func (p *Provider) DeleteRecords(ctx context.Context, zone string, recs []libdns.Record) ([]libdns.Record, error) { zoneID, err := p.findZoneID(ctx, zone) if err != nil { return nil, err } var deleted []libdns.Record for _, r := range recs { // If r.ID isn’t set, we need to look it up by matching (Type, Name, Value). // However, for a robust delete, we typically rely on ID to be sure. recordID, err := p.getRecordIDIfNeeded(ctx, zoneID, r) if err != nil { return deleted, err } if recordID == 0 { // Not found; skip continue } // DELETE /dns-zones//records/ path := fmt.Sprintf("dns-zones/%d/records/%d", zoneID, recordID) _, err = p.doRequest(ctx, http.MethodDelete, path, nil) if err != nil { return deleted, fmt.Errorf("plesk: error deleting record ID %d: %w", recordID, err) } // Mark as deleted delRec := r if r.ID == "" { // If the original r.ID was empty, fill it with the recordID we used delRec.ID = fmt.Sprintf("%d", recordID) } deleted = append(deleted, delRec) } return deleted, nil } // getRecordIDIfNeeded returns the record ID from Plesk if the libdns.Record.ID // is empty, by searching existing records for a match. Otherwise returns the // numeric value of r.ID. func (p *Provider) getRecordIDIfNeeded(ctx context.Context, zoneID int, r libdns.Record) (int, error) { if r.ID != "" { // Convert to int var recordID int _, err := fmt.Sscanf(r.ID, "%d", &recordID) if err != nil { return 0, fmt.Errorf("plesk: record ID '%s' not a valid integer: %w", r.ID, err) } return recordID, nil } // If r.ID is empty, we search. path := fmt.Sprintf("dns-zones/%d/records", zoneID) respBody, err := p.doRequest(context.Background(), http.MethodGet, path, nil) if err != nil { return 0, err } var presRecords []pleskRecord if err := json.Unmarshal(respBody, &presRecords); err != nil { return 0, fmt.Errorf("plesk: error unmarshaling DNS records: %w", err) } // We match by Type, Host, Value. (Or do a more sophisticated match if needed.) for _, rec := range presRecords { if strings.EqualFold(rec.Type, r.Type) && strings.EqualFold(rec.Host, r.Name) && rec.Value == r.Value { return rec.ID, nil } } return 0, errors.New("plesk: record not found (no ID match)") }