diff --git a/provider.go b/provider.go index 4b37c31..44c7092 100644 --- a/provider.go +++ b/provider.go @@ -1,93 +1,296 @@ package plesk import ( + "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" "net/http" + "net/url" "strings" - "time" "github.com/libdns/libdns" ) // Provider implements the libdns interfaces for Plesk. type Provider struct { - // BaseURL is the root of your Plesk API, e.g. "https://my-plesk-server:8443/api/v2" + // BaseURL is your Plesk REST API endpoint, like: + // "https://my-plesk-server:8443/api/v2" BaseURL string `json:"base_url,omitempty"` - // Authentication: could be a secret token or user/pass - // For example, if using "API secret keys" in Plesk: + // SecretToken is your Plesk API token (or pass user/pass if needed). + // Example: "abcdefghijkl123456789" SecretToken string `json:"secret_token,omitempty"` - // If you need additional fields or custom config, include them here + // HTTPClient allows custom HTTP client overrides (timeouts, etc.) + // If nil, we’ll use http.DefaultClient. + HTTPClient *http.Client } -// These ensure Provider satisfies the libdns interfaces you need. +// 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{} - // If you need updating, also do: _ libdns.RecordSetter = &Provider{} ) -// internal helper: make a request to Plesk -func (p *Provider) doRequest(ctx context.Context, method, path string, body []byte) ([]byte, error) { - url := strings.TrimSuffix(p.BaseURL, "/") + "/" + strings.TrimPrefix(path, "/") +// 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"` +} - req, err := http.NewRequestWithContext(ctx, method, url, nil) - if err != nil { - return nil, err +// 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 } - // If there's a body (for POST/PUT requests), attach it + fullURL := strings.TrimSuffix(p.BaseURL, "/") + "/" + strings.TrimPrefix(path, "/") + + var reqBody []byte + var err error if body != nil { - req.Body = http.NoBody // replace with actual body if needed - // This is just a placeholder + reqBody, err = json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("plesk: failed to marshal request body: %w", err) + } } - // Authorization: Bearer - req.Header.Set("Authorization", "Bearer "+p.SecretToken) - // Possibly set content-type to JSON or XML depending on your Plesk API - // req.Header.Set("Content-Type", "application/json") // or "application/xml" - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + req, err := http.NewRequestWithContext(ctx, method, fullURL, bytes.NewReader(reqBody)) if err != nil { - return nil, err + 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() - // read resp.Body - // ... - // if resp.StatusCode != 200, handle error - // parse JSON or XML accordingly + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("plesk: failed to read response body: %w", err) + } - // placeholder - return nil, errors.New("doRequest not fully implemented") + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("plesk: API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil } -// GetRecords lists DNS records for the given zone in Plesk +// 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) { - // Typically you'd do something like: - // GET /dns/ or /zones//records - // parse JSON/XML - // transform into []libdns.Record - // ... - return []libdns.Record{}, nil + 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) { - // For each record, make a request to Plesk to create the DNS entry - // e.g. POST /dns/ with JSON/XML specifying a record type, name, content - // Then parse the result. Return the created records in libdns format. - return recs, nil + 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) { - // For each record, figure out the record ID or how Plesk identifies it - // Then DELETE /dns// (or some Plesk-specific path) - // Return records that were successfully removed - return recs, nil + 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)") }