Files
libdns-plesk/provider.go
2025-01-07 13:09:51 +10:00

297 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, well 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=<domain>
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/<zoneID>/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/<zoneID>/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. Lets 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 isnt 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/<zoneID>/records/<recordID>
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)")
}