203 lines
4.7 KiB
Go
203 lines
4.7 KiB
Go
// Package dmapi Client for DMAPI joker.com.
|
|
// https://joker.com/faq/category/39/22-dmapi.html
|
|
package dmapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
|
"github.com/go-acme/lego/v4/log"
|
|
"github.com/go-acme/lego/v4/providers/dns/internal/errutils"
|
|
)
|
|
|
|
const defaultBaseURL = "https://dmapi.joker.com/request/"
|
|
|
|
// Response Joker DMAPI Response.
|
|
type Response struct {
|
|
Headers url.Values
|
|
Body string
|
|
StatusCode int
|
|
StatusText string
|
|
AuthSid string
|
|
}
|
|
|
|
type AuthInfo struct {
|
|
APIKey string
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
// Client a DMAPI Client.
|
|
type Client struct {
|
|
apiKey string
|
|
username string
|
|
password string
|
|
|
|
token *Token
|
|
muToken sync.Mutex
|
|
|
|
Debug bool
|
|
BaseURL string
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a new DMAPI Client.
|
|
func NewClient(authInfo AuthInfo) *Client {
|
|
return &Client{
|
|
apiKey: authInfo.APIKey,
|
|
username: authInfo.Username,
|
|
password: authInfo.Password,
|
|
BaseURL: defaultBaseURL,
|
|
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
|
}
|
|
}
|
|
|
|
// GetZone returns content of DNS zone for domain.
|
|
func (c *Client) GetZone(ctx context.Context, domain string) (*Response, error) {
|
|
if getSessionID(ctx) == "" {
|
|
return nil, errors.New("must be logged in to get zone")
|
|
}
|
|
|
|
return c.postRequest(ctx, "dns-zone-get", url.Values{"domain": {dns01.UnFqdn(domain)}})
|
|
}
|
|
|
|
// PutZone uploads DNS zone to Joker DMAPI.
|
|
func (c *Client) PutZone(ctx context.Context, domain, zone string) (*Response, error) {
|
|
if getSessionID(ctx) == "" {
|
|
return nil, errors.New("must be logged in to put zone")
|
|
}
|
|
|
|
return c.postRequest(ctx, "dns-zone-put", url.Values{"domain": {dns01.UnFqdn(domain)}, "zone": {strings.TrimSpace(zone)}})
|
|
}
|
|
|
|
// postRequest performs actual HTTP request.
|
|
func (c *Client) postRequest(ctx context.Context, cmd string, data url.Values) (*Response, error) {
|
|
endpoint, err := url.JoinPath(c.BaseURL, cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if getSessionID(ctx) != "" {
|
|
data.Set("auth-sid", getSessionID(ctx))
|
|
}
|
|
|
|
if c.Debug {
|
|
log.Infof("postRequest:\n\tURL: %q\n\tData: %v", endpoint, data)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, errutils.NewHTTPDoError(req, err)
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
|
|
}
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
|
|
}
|
|
|
|
return parseResponse(string(raw)), nil
|
|
}
|
|
|
|
// parseResponse parses HTTP response body.
|
|
func parseResponse(message string) *Response {
|
|
r := &Response{Headers: url.Values{}, StatusCode: -1}
|
|
|
|
lines, body, _ := strings.Cut(message, "\n\n")
|
|
|
|
for _, line := range strings.Split(lines, "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
k, v, _ := strings.Cut(line, ":")
|
|
|
|
val := strings.TrimSpace(v)
|
|
|
|
r.Headers.Add(k, val)
|
|
|
|
switch k {
|
|
case "Status-Code":
|
|
i, err := strconv.Atoi(val)
|
|
if err == nil {
|
|
r.StatusCode = i
|
|
}
|
|
case "Status-Text":
|
|
r.StatusText = val
|
|
case "Auth-Sid":
|
|
r.AuthSid = val
|
|
}
|
|
}
|
|
|
|
r.Body = body
|
|
|
|
return r
|
|
}
|
|
|
|
// Temporary workaround, until it get fixed on API side.
|
|
func fixTxtLines(line string) string {
|
|
fields := strings.Fields(line)
|
|
|
|
if len(fields) < 6 || fields[1] != "TXT" {
|
|
return line
|
|
}
|
|
|
|
if fields[3][0] == '"' && fields[4] == `"` {
|
|
fields[3] = strings.TrimSpace(fields[3]) + `"`
|
|
fields = append(fields[:4], fields[5:]...)
|
|
}
|
|
|
|
return strings.Join(fields, " ")
|
|
}
|
|
|
|
// RemoveTxtEntryFromZone clean-ups all TXT records with given name.
|
|
func RemoveTxtEntryFromZone(zone, relative string) (string, bool) {
|
|
prefix := fmt.Sprintf("%s TXT 0 ", relative)
|
|
|
|
modified := false
|
|
var zoneEntries []string
|
|
for _, line := range strings.Split(zone, "\n") {
|
|
if strings.HasPrefix(line, prefix) {
|
|
modified = true
|
|
continue
|
|
}
|
|
zoneEntries = append(zoneEntries, line)
|
|
}
|
|
|
|
return strings.TrimSpace(strings.Join(zoneEntries, "\n")), modified
|
|
}
|
|
|
|
// AddTxtEntryToZone returns DNS zone with added TXT record.
|
|
func AddTxtEntryToZone(zone, relative, value string, ttl int) string {
|
|
var zoneEntries []string
|
|
|
|
for _, line := range strings.Split(zone, "\n") {
|
|
zoneEntries = append(zoneEntries, fixTxtLines(line))
|
|
}
|
|
|
|
newZoneEntry := fmt.Sprintf("%s TXT 0 %q %d", relative, value, ttl)
|
|
zoneEntries = append(zoneEntries, newZoneEntry)
|
|
|
|
return strings.TrimSpace(strings.Join(zoneEntries, "\n"))
|
|
}
|