1package internal
2
3import (
4	"bytes"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"io"
9	"net/http"
10	"time"
11)
12
13// defaultBaseURL for reaching the jSON-based API-Endpoint of netcup.
14const defaultBaseURL = "https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON"
15
16// success response status.
17const success = "success"
18
19// Request wrapper as specified in netcup wiki
20// needed for every request to netcup API around *Msg.
21// https://www.netcup-wiki.de/wiki/CCP_API#Anmerkungen_zu_JSON-Requests
22type Request struct {
23	Action string      `json:"action"`
24	Param  interface{} `json:"param"`
25}
26
27// LoginRequest as specified in netcup WSDL.
28// https://ccp.netcup.net/run/webservice/servers/endpoint.php#login
29type LoginRequest struct {
30	CustomerNumber  string `json:"customernumber"`
31	APIKey          string `json:"apikey"`
32	APIPassword     string `json:"apipassword"`
33	ClientRequestID string `json:"clientrequestid,omitempty"`
34}
35
36// LogoutRequest as specified in netcup WSDL.
37// https://ccp.netcup.net/run/webservice/servers/endpoint.php#logout
38type LogoutRequest struct {
39	CustomerNumber  string `json:"customernumber"`
40	APIKey          string `json:"apikey"`
41	APISessionID    string `json:"apisessionid"`
42	ClientRequestID string `json:"clientrequestid,omitempty"`
43}
44
45// UpdateDNSRecordsRequest as specified in netcup WSDL.
46// https://ccp.netcup.net/run/webservice/servers/endpoint.php#updateDnsRecords
47type UpdateDNSRecordsRequest struct {
48	DomainName      string       `json:"domainname"`
49	CustomerNumber  string       `json:"customernumber"`
50	APIKey          string       `json:"apikey"`
51	APISessionID    string       `json:"apisessionid"`
52	ClientRequestID string       `json:"clientrequestid,omitempty"`
53	DNSRecordSet    DNSRecordSet `json:"dnsrecordset"`
54}
55
56// DNSRecordSet as specified in netcup WSDL.
57// needed in UpdateDNSRecordsRequest.
58// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecordset
59type DNSRecordSet struct {
60	DNSRecords []DNSRecord `json:"dnsrecords"`
61}
62
63// InfoDNSRecordsRequest as specified in netcup WSDL.
64// https://ccp.netcup.net/run/webservice/servers/endpoint.php#infoDnsRecords
65type InfoDNSRecordsRequest struct {
66	DomainName      string `json:"domainname"`
67	CustomerNumber  string `json:"customernumber"`
68	APIKey          string `json:"apikey"`
69	APISessionID    string `json:"apisessionid"`
70	ClientRequestID string `json:"clientrequestid,omitempty"`
71}
72
73// DNSRecord as specified in netcup WSDL.
74// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Dnsrecord
75type DNSRecord struct {
76	ID           int    `json:"id,string,omitempty"`
77	Hostname     string `json:"hostname"`
78	RecordType   string `json:"type"`
79	Priority     string `json:"priority,omitempty"`
80	Destination  string `json:"destination"`
81	DeleteRecord bool   `json:"deleterecord,omitempty"`
82	State        string `json:"state,omitempty"`
83	TTL          int    `json:"ttl,omitempty"`
84}
85
86// ResponseMsg as specified in netcup WSDL.
87// https://ccp.netcup.net/run/webservice/servers/endpoint.php#Responsemessage
88type ResponseMsg struct {
89	ServerRequestID string          `json:"serverrequestid"`
90	ClientRequestID string          `json:"clientrequestid,omitempty"`
91	Action          string          `json:"action"`
92	Status          string          `json:"status"`
93	StatusCode      int             `json:"statuscode"`
94	ShortMessage    string          `json:"shortmessage"`
95	LongMessage     string          `json:"longmessage"`
96	ResponseData    json.RawMessage `json:"responsedata,omitempty"`
97}
98
99func (r *ResponseMsg) Error() string {
100	return fmt.Sprintf("an error occurred during the action %s: [Status=%s, StatusCode=%d, ShortMessage=%s, LongMessage=%s]",
101		r.Action, r.Status, r.StatusCode, r.ShortMessage, r.LongMessage)
102}
103
104// LoginResponse response to login action.
105type LoginResponse struct {
106	APISessionID string `json:"apisessionid"`
107}
108
109// InfoDNSRecordsResponse response to infoDnsRecords action.
110type InfoDNSRecordsResponse struct {
111	APISessionID string      `json:"apisessionid"`
112	DNSRecords   []DNSRecord `json:"dnsrecords,omitempty"`
113}
114
115// Client netcup DNS client.
116type Client struct {
117	customerNumber string
118	apiKey         string
119	apiPassword    string
120	HTTPClient     *http.Client
121	BaseURL        string
122}
123
124// NewClient creates a netcup DNS client.
125func NewClient(customerNumber, apiKey, apiPassword string) (*Client, error) {
126	if customerNumber == "" || apiKey == "" || apiPassword == "" {
127		return nil, errors.New("credentials missing")
128	}
129
130	return &Client{
131		customerNumber: customerNumber,
132		apiKey:         apiKey,
133		apiPassword:    apiPassword,
134		BaseURL:        defaultBaseURL,
135		HTTPClient: &http.Client{
136			Timeout: 10 * time.Second,
137		},
138	}, nil
139}
140
141// Login performs the login as specified by the netcup WSDL
142// returns sessionID needed to perform remaining actions.
143// https://ccp.netcup.net/run/webservice/servers/endpoint.php
144func (c *Client) Login() (string, error) {
145	payload := &Request{
146		Action: "login",
147		Param: &LoginRequest{
148			CustomerNumber:  c.customerNumber,
149			APIKey:          c.apiKey,
150			APIPassword:     c.apiPassword,
151			ClientRequestID: "",
152		},
153	}
154
155	var responseData LoginResponse
156	err := c.doRequest(payload, &responseData)
157	if err != nil {
158		return "", fmt.Errorf("loging error: %w", err)
159	}
160
161	return responseData.APISessionID, nil
162}
163
164// Logout performs the logout with the supplied sessionID as specified by the netcup WSDL.
165// https://ccp.netcup.net/run/webservice/servers/endpoint.php
166func (c *Client) Logout(sessionID string) error {
167	payload := &Request{
168		Action: "logout",
169		Param: &LogoutRequest{
170			CustomerNumber:  c.customerNumber,
171			APIKey:          c.apiKey,
172			APISessionID:    sessionID,
173			ClientRequestID: "",
174		},
175	}
176
177	err := c.doRequest(payload, nil)
178	if err != nil {
179		return fmt.Errorf("logout error: %w", err)
180	}
181
182	return nil
183}
184
185// UpdateDNSRecord performs an update of the DNSRecords as specified by the netcup WSDL.
186// https://ccp.netcup.net/run/webservice/servers/endpoint.php
187func (c *Client) UpdateDNSRecord(sessionID, domainName string, records []DNSRecord) error {
188	payload := &Request{
189		Action: "updateDnsRecords",
190		Param: UpdateDNSRecordsRequest{
191			DomainName:      domainName,
192			CustomerNumber:  c.customerNumber,
193			APIKey:          c.apiKey,
194			APISessionID:    sessionID,
195			ClientRequestID: "",
196			DNSRecordSet:    DNSRecordSet{DNSRecords: records},
197		},
198	}
199
200	err := c.doRequest(payload, nil)
201	if err != nil {
202		return fmt.Errorf("error when sending the request: %w", err)
203	}
204
205	return nil
206}
207
208// GetDNSRecords retrieves all dns records of an DNS-Zone as specified by the netcup WSDL
209// returns an array of DNSRecords.
210// https://ccp.netcup.net/run/webservice/servers/endpoint.php
211func (c *Client) GetDNSRecords(hostname, apiSessionID string) ([]DNSRecord, error) {
212	payload := &Request{
213		Action: "infoDnsRecords",
214		Param: InfoDNSRecordsRequest{
215			DomainName:      hostname,
216			CustomerNumber:  c.customerNumber,
217			APIKey:          c.apiKey,
218			APISessionID:    apiSessionID,
219			ClientRequestID: "",
220		},
221	}
222
223	var responseData InfoDNSRecordsResponse
224	err := c.doRequest(payload, &responseData)
225	if err != nil {
226		return nil, fmt.Errorf("error when sending the request: %w", err)
227	}
228
229	return responseData.DNSRecords, nil
230}
231
232// doRequest marshals given body to JSON, send the request to netcup API
233// and returns body of response.
234func (c *Client) doRequest(payload, responseData interface{}) error {
235	body, err := json.Marshal(payload)
236	if err != nil {
237		return err
238	}
239
240	req, err := http.NewRequest(http.MethodPost, c.BaseURL, bytes.NewReader(body))
241	if err != nil {
242		return err
243	}
244
245	req.Close = true
246	req.Header.Set("content-type", "application/json")
247
248	resp, err := c.HTTPClient.Do(req)
249	if err != nil {
250		return err
251	}
252
253	if err = checkResponse(resp); err != nil {
254		return err
255	}
256
257	respMsg, err := decodeResponseMsg(resp)
258	if err != nil {
259		return err
260	}
261
262	if respMsg.Status != success {
263		return respMsg
264	}
265
266	if responseData != nil {
267		err = json.Unmarshal(respMsg.ResponseData, responseData)
268		if err != nil {
269			return fmt.Errorf("%v: unmarshaling %T error: %w: %s",
270				respMsg, responseData, err, string(respMsg.ResponseData))
271		}
272	}
273
274	return nil
275}
276
277func checkResponse(resp *http.Response) error {
278	if resp.StatusCode > 299 {
279		if resp.Body == nil {
280			return fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
281		}
282
283		defer resp.Body.Close()
284
285		raw, err := io.ReadAll(resp.Body)
286		if err != nil {
287			return fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
288		}
289
290		return fmt.Errorf("status code=%d: %s", resp.StatusCode, string(raw))
291	}
292
293	return nil
294}
295
296func decodeResponseMsg(resp *http.Response) (*ResponseMsg, error) {
297	if resp.Body == nil {
298		return nil, fmt.Errorf("response body is nil, status code=%d", resp.StatusCode)
299	}
300
301	defer resp.Body.Close()
302
303	raw, err := io.ReadAll(resp.Body)
304	if err != nil {
305		return nil, fmt.Errorf("unable to read body: status code=%d, error=%w", resp.StatusCode, err)
306	}
307
308	var respMsg ResponseMsg
309	err = json.Unmarshal(raw, &respMsg)
310	if err != nil {
311		return nil, fmt.Errorf("unmarshaling %T error [status code=%d]: %w: %s", respMsg, resp.StatusCode, err, string(raw))
312	}
313
314	return &respMsg, nil
315}
316
317// GetDNSRecordIdx searches a given array of DNSRecords for a given DNSRecord
318// equivalence is determined by Destination and RecortType attributes
319// returns index of given DNSRecord in given array of DNSRecords.
320func GetDNSRecordIdx(records []DNSRecord, record DNSRecord) (int, error) {
321	for index, element := range records {
322		if record.Destination == element.Destination && record.RecordType == element.RecordType {
323			return index, nil
324		}
325	}
326	return -1, errors.New("no DNS Record found")
327}
328