client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/xml"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "strings"
12 "time"
13
14 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
15 )
16
17 // DefaultBaseURL is url to the XML-RPC api.
18 const DefaultBaseURL = "https://api.loopia.se/RPCSERV"
19
20 // Client the Loopia client.
21 type Client struct {
22 apiUser string
23 apiPassword string
24
25 BaseURL string
26 HTTPClient *http.Client
27 }
28
29 // NewClient creates a new Loopia Client.
30 func NewClient(apiUser, apiPassword string) *Client {
31 return &Client{
32 apiUser: apiUser,
33 apiPassword: apiPassword,
34 BaseURL: DefaultBaseURL,
35 HTTPClient: &http.Client{Timeout: 10 * time.Second},
36 }
37 }
38
39 // AddTXTRecord adds a TXT record.
40 func (c *Client) AddTXTRecord(ctx context.Context, domain, subdomain string, ttl int, value string) error {
41 call := &methodCall{
42 MethodName: "addZoneRecord",
43 Params: []param{
44 paramString{Value: c.apiUser},
45 paramString{Value: c.apiPassword},
46 paramString{Value: domain},
47 paramString{Value: subdomain},
48 paramStruct{
49 StructMembers: []structMember{
50 structMemberString{Name: "type", Value: "TXT"},
51 structMemberInt{Name: "ttl", Value: ttl},
52 structMemberInt{Name: "priority", Value: 0},
53 structMemberString{Name: "rdata", Value: value},
54 structMemberInt{Name: "record_id", Value: 0},
55 },
56 },
57 },
58 }
59 resp := &responseString{}
60
61 err := c.rpcCall(ctx, call, resp)
62 if err != nil {
63 return err
64 }
65
66 return checkResponse(resp.Value)
67 }
68
69 // RemoveTXTRecord removes a TXT record.
70 func (c *Client) RemoveTXTRecord(ctx context.Context, domain, subdomain string, recordID int) error {
71 call := &methodCall{
72 MethodName: "removeZoneRecord",
73 Params: []param{
74 paramString{Value: c.apiUser},
75 paramString{Value: c.apiPassword},
76 paramString{Value: domain},
77 paramString{Value: subdomain},
78 paramInt{Value: recordID},
79 },
80 }
81 resp := &responseString{}
82
83 err := c.rpcCall(ctx, call, resp)
84 if err != nil {
85 return err
86 }
87
88 return checkResponse(resp.Value)
89 }
90
91 // GetTXTRecords gets TXT records.
92 func (c *Client) GetTXTRecords(ctx context.Context, domain, subdomain string) ([]RecordObj, error) {
93 call := &methodCall{
94 MethodName: "getZoneRecords",
95 Params: []param{
96 paramString{Value: c.apiUser},
97 paramString{Value: c.apiPassword},
98 paramString{Value: domain},
99 paramString{Value: subdomain},
100 },
101 }
102 resp := &recordObjectsResponse{}
103
104 err := c.rpcCall(ctx, call, resp)
105
106 return resp.Params, err
107 }
108
109 // RemoveSubdomain remove a sub-domain.
110 func (c *Client) RemoveSubdomain(ctx context.Context, domain, subdomain string) error {
111 call := &methodCall{
112 MethodName: "removeSubdomain",
113 Params: []param{
114 paramString{Value: c.apiUser},
115 paramString{Value: c.apiPassword},
116 paramString{Value: domain},
117 paramString{Value: subdomain},
118 },
119 }
120 resp := &responseString{}
121
122 err := c.rpcCall(ctx, call, resp)
123 if err != nil {
124 return err
125 }
126
127 return checkResponse(resp.Value)
128 }
129
130 // rpcCall makes an XML-RPC call to Loopia's RPC endpoint by marshaling the data given in the call argument to XML
131 // and sending that via HTTP Post to Loopia.
132 // The response is then unmarshalled into the resp argument.
133 func (c *Client) rpcCall(ctx context.Context, call *methodCall, result response) error {
134 req, err := newXMLRequest(ctx, c.BaseURL, call)
135 if err != nil {
136 return err
137 }
138
139 resp, err := c.HTTPClient.Do(req)
140 if err != nil {
141 return errutils.NewHTTPDoError(req, err)
142 }
143
144 defer func() { _ = resp.Body.Close() }()
145
146 if resp.StatusCode != http.StatusOK {
147 return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
148 }
149
150 raw, err := io.ReadAll(resp.Body)
151 if err != nil {
152 return errutils.NewReadResponseError(req, resp.StatusCode, err)
153 }
154
155 err = xml.Unmarshal(raw, result)
156 if err != nil {
157 return fmt.Errorf("unmarshal error: %w", err)
158 }
159
160 if result.faultCode() != 0 {
161 return RPCError{
162 FaultCode: result.faultCode(),
163 FaultString: strings.TrimSpace(result.faultString()),
164 }
165 }
166
167 return nil
168 }
169
170 func newXMLRequest(ctx context.Context, endpoint string, payload any) (*http.Request, error) {
171 body := new(bytes.Buffer)
172 body.WriteString(xml.Header)
173
174 encoder := xml.NewEncoder(body)
175 encoder.Indent("", " ")
176
177 err := encoder.Encode(payload)
178 if err != nil {
179 return nil, err
180 }
181
182 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
183 if err != nil {
184 return nil, fmt.Errorf("unable to create request: %w", err)
185 }
186
187 req.Header.Set("Content-Type", "text/xml")
188
189 return req, nil
190 }
191
192 func checkResponse(value string) error {
193 switch v := strings.TrimSpace(value); v {
194 case "OK":
195 return nil
196 case "AUTH_ERROR":
197 return errors.New("authentication error")
198 default:
199 return fmt.Errorf("unknown error: %q", v)
200 }
201 }
202