1 Commits
0.0.1 ... main

Author SHA1 Message Date
6419eebdd3 Implement plesk rest api 2025-01-07 13:09:51 +10:00

View File

@ -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, well 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 <token>
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=<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) {
// Typically you'd do something like:
// GET /dns/<zone> or /zones/<zone>/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/<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) {
// For each record, make a request to Plesk to create the DNS entry
// e.g. POST /dns/<zone> 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/<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) {
// For each record, figure out the record ID or how Plesk identifies it
// Then DELETE /dns/<zone>/<recordID> (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 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)")
}